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) {
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)
142}
143
144fn parse_with_tree_sitter(script: &str) -> Result<Vec<Vec<String>>, String> {
146 let parser_guard = get_bash_parser()?;
147 let mut parser = parser_guard.lock().map_err(|e| format!("Failed to lock parser: {e}"))?;
148
149 let tree = parser.parse(script, None).ok_or_else(|| "Failed to parse script".to_string())?;
150
151 let mut commands = Vec::new();
152 let root = tree.root_node();
153
154 collect_commands_from_node(root, script, &mut commands);
158
159 Ok(commands)
160}
161
162fn collect_commands_from_node(node: tree_sitter::Node, source: &str, commands: &mut Vec<Vec<String>>) {
163 match node.kind() {
164 "command" | "simple_command" => {
165 if let Some(cmd) = extract_command_from_node(node, source)
166 && !cmd.is_empty()
167 {
168 commands.push(cmd);
169 }
170 }
171 _ => {
172 let mut cursor = node.walk();
173 for child in node.children(&mut cursor) {
174 collect_commands_from_node(child, source, commands);
175 }
176 }
177 }
178}
179
180fn extract_command_from_node(node: tree_sitter::Node, source: &str) -> Option<Vec<String>> {
182 let mut command = Vec::new();
183 let mut cursor = node.walk();
184
185 if node.kind() == "pipeline" {
187 for child in node.children(&mut cursor) {
188 if child.kind() == "command" || child.kind() == "simple_command" {
189 return extract_command_from_node(child, source);
190 }
191 }
192 }
193
194 for child in node.children(&mut cursor) {
196 if child.kind() == "command_name" {
197 if let Ok(arg) = child.utf8_text(source.as_bytes()) {
198 let trimmed = arg.trim();
199 if !trimmed.is_empty() {
200 command.push(trimmed.to_string());
201 }
202 }
203 continue;
204 }
205
206 if matches!(child.kind(), "word" | "string" | "simple_expansion" | "variable_expansion") {
207 let text = child.utf8_text(source.as_bytes());
208 if let Ok(arg) = text {
209 let trimmed = arg.trim();
210 if !trimmed.is_empty() {
211 command.push(trimmed.to_string());
212 }
213 }
214 }
215 }
216
217 if command.is_empty() { None } else { Some(command) }
218}
219
220fn parse_with_basic_tokenization(script: &str) -> Result<Vec<Vec<String>>, String> {
222 let mut commands = Vec::new();
223 let mut current_command = String::new();
224 let mut in_quotes = false;
225 let mut quote_char = ' ';
226 let mut escaped = false;
227
228 for ch in script.chars() {
229 if escaped {
230 current_command.push(ch);
231 escaped = false;
232 continue;
233 }
234
235 match ch {
236 '\\' => {
237 escaped = true;
238 }
239 '\'' | '"' if !in_quotes => {
240 in_quotes = true;
241 quote_char = ch;
242 }
243 c if c == quote_char && in_quotes => {
244 in_quotes = false;
245 }
246 '&' | '|' | ';' if !in_quotes => {
247 if !current_command.trim().is_empty()
248 && let Ok(cmd) = tokenize_command(¤t_command)
249 {
250 commands.push(cmd);
251 }
252 current_command.clear();
253 }
254 _ => current_command.push(ch),
255 }
256 }
257
258 if !current_command.trim().is_empty()
259 && let Ok(cmd) = tokenize_command(¤t_command)
260 {
261 commands.push(cmd);
262 }
263
264 Ok(commands)
265}
266
267fn tokenize_command(cmd: &str) -> Result<Vec<String>, String> {
270 shell_words::split(cmd).map_err(|err| format!("failed to tokenize command: {err}"))
271}
272
273pub fn parse_bash_lc_commands(command: &[String]) -> Option<Vec<Vec<String>>> {
281 if command.is_empty() {
282 return None;
283 }
284
285 let cmd_name = command[0].as_str();
286 let base_cmd = std::path::Path::new(cmd_name)
287 .file_name()
288 .and_then(|osstr| osstr.to_str())
289 .unwrap_or("");
290
291 if base_cmd != "bash" && base_cmd != "zsh" && base_cmd != "sh" {
292 return None;
293 }
294
295 for window in command.windows(2) {
297 if matches!(window[0].as_str(), "-lc" | "-c" | "-il" | "-ic") {
298 let script = &window[1];
299 return parse_shell_commands(script).ok();
300 }
301 }
302
303 None
304}
305
306#[cfg(test)]
307mod tests {
308 use super::*;
309
310 #[test]
311 fn tokenize_simple_command() {
312 let cmd = "git status";
313 let tokens = tokenize_command(cmd).unwrap();
314 assert_eq!(tokens, vec!["git", "status"]);
315 }
316
317 #[test]
318 fn tokenize_quoted_arguments() {
319 let cmd = r#"echo "hello world""#;
320 let tokens = tokenize_command(cmd).unwrap();
321 assert_eq!(tokens, vec!["echo", "hello world"]);
322 }
323
324 #[test]
325 fn parse_single_command() {
326 let script = "git status";
327 let commands = parse_shell_commands(script).unwrap();
328 assert_eq!(commands.len(), 1);
329 assert_eq!(commands[0][0], "git");
330 }
331
332 #[test]
333 fn parse_chained_commands_with_and() {
334 let script = "git status && cargo check";
335 let commands = parse_shell_commands(script).unwrap();
336 assert_eq!(commands.len(), 2);
337 assert_eq!(commands[0][0], "git");
338 assert_eq!(commands[1][0], "cargo");
339 }
340
341 #[test]
342 fn parse_loop_body_commands() {
343 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";
344 let commands = parse_shell_commands(script).unwrap();
345
346 assert_eq!(commands[0], vec!["cd", "crates/codegen/vtcode-core/src/tools/registry"]);
347 assert!(
348 commands
349 .iter()
350 .any(|command| command.first().is_some_and(|name| name == "echo"))
351 );
352 assert!(
353 commands
354 .iter()
355 .any(|command| command.first().is_some_and(|name| name == "grep"))
356 );
357 assert!(
358 commands
359 .iter()
360 .any(|command| command.first().is_some_and(|name| name == "head"))
361 );
362 }
363
364 #[test]
365 fn parse_chained_commands_with_semicolon() {
366 let script = "git status; cargo check";
367 let commands = parse_shell_commands(script).unwrap();
368 assert_eq!(commands.len(), 2);
369 }
370
371 #[test]
372 fn parse_bash_lc_git_status() {
373 let cmd = vec!["bash".to_string(), "-lc".to_string(), "git status".to_string()];
374 let commands = parse_bash_lc_commands(&cmd);
375 assert!(commands.is_some());
376 let commands = commands.unwrap();
377 assert_eq!(commands.len(), 1);
378 assert_eq!(commands[0][0], "git");
379 }
380
381 #[test]
382 fn parse_bash_lc_chained() {
383 let cmd = vec![
384 "bash".to_string(),
385 "-lc".to_string(),
386 "git status && cargo check".to_string(),
387 ];
388 let commands = parse_bash_lc_commands(&cmd);
389 assert!(commands.is_some());
390 let commands = commands.unwrap();
391 assert_eq!(commands.len(), 2);
392 }
393
394 #[test]
395 fn parse_non_bash_command_returns_none() {
396 let cmd = vec!["echo".to_string(), "hello".to_string()];
397 let commands = parse_bash_lc_commands(&cmd);
398 assert!(commands.is_none());
399 }
400
401 #[test]
402 fn parse_bash_without_lc_returns_none() {
403 let cmd = vec!["bash".to_string(), "script.sh".to_string()];
404 let commands = parse_bash_lc_commands(&cmd);
405 assert!(commands.is_none());
406 }
407
408 #[test]
411 fn parse_complex_pipeline() {
412 let script = "cat file.txt | grep -i pattern | sort";
413 let commands = parse_shell_commands(script).unwrap();
414 assert!(!commands.is_empty());
415 }
416
417 #[test]
418 fn parse_with_pipes_and_redirects() {
419 let script = "ls -la | grep file > output.txt";
420 let commands = parse_shell_commands(script).unwrap();
421 assert!(!commands.is_empty());
422 }
423
424 #[test]
425 fn parse_command_substitution_fallback() {
426 let script = "echo $(git status)";
427 let commands = parse_shell_commands(script).unwrap();
428 assert!(!commands.is_empty());
429 }
430
431 #[test]
432 fn parse_escaped_quotes() {
433 let script = r#"echo "hello \"world\"""#;
434 let commands = parse_shell_commands(script).unwrap();
435 assert!(!commands.is_empty());
436 }
437
438 #[test]
439 fn parse_tree_sitter_preserves_command_name_with_quoted_args() {
440 let script = r#"echo "fish and chips""#;
441 let commands = parse_shell_commands_tree_sitter(script).unwrap();
442 assert!(!commands.is_empty());
443 assert_eq!(commands[0][0], "echo");
444 }
445
446 #[test]
447 fn parse_bash_lc_with_pipe() {
448 let cmd = vec!["bash".to_string(), "-lc".to_string(), "ls -la | head -5".to_string()];
449 let commands = parse_bash_lc_commands(&cmd);
450 assert!(commands.is_some());
451 let cmds = commands.unwrap();
452 assert!(!cmds.is_empty());
453 }
454
455 #[test]
456 fn parse_dangerous_shell_command() {
457 let script = "rm -rf /; echo done";
458 let commands = parse_shell_commands(script).unwrap();
459 assert_eq!(commands.len(), 2);
460 assert_eq!(commands[0][0], "rm");
461 }
462
463 #[test]
464 fn prewarm_bash_parser_initializes_successfully() {
465 prewarm_bash_parser().expect("bash parser should initialize");
466 }
467
468 #[test]
469 fn dynamic_find_syntax_is_detected_without_rejecting_quoted_globs() {
470 assert!(contains_dynamic_find_syntax("find src -maxdepth 0 -exe$''c touch /tmp/VT_BYPASS_POC {} +"));
471 assert!(!contains_dynamic_find_syntax("find src -type f -name '*.rs'"));
472 }
473}
474
475use anyhow::{Result, bail};
478
479#[derive(Clone, Copy, Eq, PartialEq)]
481enum QuoteState {
482 None,
483 Single,
484 Double,
485}
486
487pub(crate) fn split_shell_segments(command: &str) -> Result<Vec<String>> {
490 let mut segments = Vec::new();
491 let mut state = QuoteState::None;
492 let mut escaped = false;
493 let mut segment_start = 0usize;
494 let mut chars = command.char_indices().peekable();
495
496 while let Some((idx, ch)) = chars.next() {
497 match state {
498 QuoteState::Single => {
499 if ch == '\'' {
500 state = QuoteState::None;
501 }
502 }
503 QuoteState::Double => {
504 if escaped {
505 escaped = false;
506 continue;
507 }
508
509 match ch {
510 '\\' => escaped = true,
511 '"' => state = QuoteState::None,
512 '`' => bail!("Command injection pattern detected"),
513 '$' if matches!(chars.peek(), Some((_, '('))) => {
514 bail!("Command injection pattern detected");
515 }
516 _ => {}
517 }
518 }
519 QuoteState::None => {
520 if escaped {
521 escaped = false;
522 continue;
523 }
524
525 match ch {
526 '\\' => escaped = true,
527 '\'' => state = QuoteState::Single,
528 '"' => state = QuoteState::Double,
529 '`' => bail!("Command injection pattern detected"),
530 '$' if matches!(chars.peek(), Some((_, '('))) => {
531 bail!("Command injection pattern detected");
532 }
533 ';' => bail!("Unquoted command chaining detected"),
534 '\n' => bail!("Command injection pattern detected"),
535 '|' | '&' => {
536 push_segment(command, segment_start, idx, &mut segments);
537 segment_start = idx + ch.len_utf8();
538 if let Some((next_idx, next_ch)) = chars.peek().copied()
539 && next_ch == ch
540 {
541 let _next = chars.next();
542 segment_start = next_idx + next_ch.len_utf8();
543 }
544 }
545 _ => {}
546 }
547 }
548 }
549 }
550
551 push_segment(command, segment_start, command.len(), &mut segments);
552 Ok(segments)
553}
554
555fn push_segment(command: &str, start: usize, end: usize, segments: &mut Vec<String>) {
556 let segment = command[start..end].trim();
557 if !segment.is_empty() {
558 segments.push(segment.to_string());
559 }
560}
561
562pub(crate) fn additional_dangerous_pattern(segment: &str) -> Option<&'static str> {
564 let segment_lower = segment.to_ascii_lowercase();
565 if segment_lower.starts_with(":(){:|:&};:") {
566 return Some(":(){:|:&};:");
567 }
568
569 let tokens =
570 shell_words::split(segment).unwrap_or_else(|_| segment.split_whitespace().map(ToString::to_string).collect());
571 let first = tokens.first()?;
572 let command_name = base_command_name(strip_wrapping_quotes(first)).to_ascii_lowercase();
573
574 match command_name.as_str() {
575 "rmdir" => Some("rmdir"),
576 "wget" => Some("wget"),
577 "curl" => Some("curl"),
578 "chmod" if tokens.iter().skip(1).any(|arg| strip_wrapping_quotes(arg).starts_with("777")) => Some("chmod 777"),
579 "chown"
580 if tokens.iter().skip(1).any(|arg| {
581 let arg = strip_wrapping_quotes(arg).to_ascii_lowercase();
582 arg == "root" || arg.starts_with("root:")
583 }) =>
584 {
585 Some("chown root")
586 }
587 _ => None,
588 }
589}
590
591fn strip_wrapping_quotes(token: &str) -> &str {
592 token
593 .strip_prefix('\'')
594 .and_then(|token| token.strip_suffix('\''))
595 .or_else(|| token.strip_prefix('"').and_then(|token| token.strip_suffix('"')))
596 .unwrap_or(token)
597}
598
599fn base_command_name(command: &str) -> &str {
600 std::path::Path::new(command)
601 .file_name()
602 .and_then(|name| name.to_str())
603 .unwrap_or(command)
604}