vtcode_safety/command_safety/
shell_parser.rs1use std::sync::Mutex;
17use std::sync::OnceLock;
18
19static BASH_PARSER: OnceLock<Result<Mutex<tree_sitter::Parser>, String>> = OnceLock::new();
21
22pub fn contains_dynamic_shell_syntax(command: &str) -> bool {
30 enum ShellQuote {
31 Single,
32 Double,
33 }
34
35 let mut quote: Option<ShellQuote> = None;
36
37 for character in command.chars() {
38 match quote {
39 Some(ShellQuote::Single) => {
40 if character == '\'' {
41 quote = None;
42 }
43 }
44 Some(ShellQuote::Double) => match character {
45 '"' => quote = None,
46 '\\' | '$' | '`' => return true,
47 _ => {}
48 },
49 None => match character {
50 '\'' => quote = Some(ShellQuote::Single),
51 '"' => quote = Some(ShellQuote::Double),
52 '\\' | '$' | '`' | '{' | '}' | '*' | '?' | '[' | ']' => return true,
53 _ => {}
54 },
55 }
56 }
57
58 quote.is_some()
59}
60
61pub fn contains_dynamic_find_syntax(script: &str) -> bool {
64 if let Ok(commands) = parse_shell_commands_tree_sitter(script)
65 && commands.iter().any(|command| {
66 command
67 .first()
68 .map(|program| base_command_name(program) == "find")
69 .unwrap_or(false)
70 && command.iter().any(|word| contains_dynamic_shell_syntax(word))
71 })
72 {
73 return true;
74 }
75
76 let has_find_word = script.split_whitespace().any(|word| {
80 let command = word.trim_matches(|character: char| !character.is_ascii_alphanumeric() && character != '/');
81 base_command_name(command) == "find"
82 });
83 has_find_word && contains_dynamic_shell_syntax(script)
84}
85
86fn get_bash_parser() -> Result<&'static Mutex<tree_sitter::Parser>, String> {
88 BASH_PARSER
89 .get_or_init(|| {
90 let mut parser = tree_sitter::Parser::new();
91 let lang: tree_sitter::Language = tree_sitter_bash::LANGUAGE.into();
92 parser
93 .set_language(&lang)
94 .map_err(|e| format!("Failed to load bash grammar: {e}"))?;
95 Ok(Mutex::new(parser))
96 })
97 .as_ref()
98 .map_err(Clone::clone)
99}
100
101pub fn prewarm_bash_parser() -> Result<(), String> {
103 let _ = get_bash_parser()?;
104 Ok(())
105}
106
107pub fn parse_shell_commands(script: &str) -> Result<Vec<Vec<String>>, String> {
118 match parse_with_tree_sitter(script) {
120 Ok(commands) if !commands.is_empty() => return Ok(commands),
121 Ok(_) => {} Err(e) => {
123 tracing::debug!("Tree-sitter bash parsing failed: {}, falling back to basic tokenization", e);
124 }
125 }
126
127 parse_with_basic_tokenization(script)
129}
130
131pub fn parse_shell_commands_tree_sitter(script: &str) -> Result<Vec<Vec<String>>, String> {
135 parse_with_tree_sitter(script)
136}
137
138fn parse_with_tree_sitter(script: &str) -> Result<Vec<Vec<String>>, String> {
140 let parser_guard = get_bash_parser()?;
141 let mut parser = parser_guard.lock().map_err(|e| format!("Failed to lock parser: {e}"))?;
142
143 let tree = parser.parse(script, None).ok_or_else(|| "Failed to parse script".to_string())?;
144
145 let mut commands = Vec::new();
146 let root = tree.root_node();
147
148 collect_commands_from_node(root, script, &mut commands);
152
153 Ok(commands)
154}
155
156fn collect_commands_from_node(node: tree_sitter::Node, source: &str, commands: &mut Vec<Vec<String>>) {
157 match node.kind() {
158 "command" | "simple_command" => {
159 if let Some(cmd) = extract_command_from_node(node, source)
160 && !cmd.is_empty()
161 {
162 commands.push(cmd);
163 }
164 }
165 _ => {
166 let mut cursor = node.walk();
167 for child in node.children(&mut cursor) {
168 collect_commands_from_node(child, source, commands);
169 }
170 }
171 }
172}
173
174fn extract_command_from_node(node: tree_sitter::Node, source: &str) -> Option<Vec<String>> {
176 let mut command = Vec::new();
177 let mut cursor = node.walk();
178
179 if node.kind() == "pipeline" {
181 for child in node.children(&mut cursor) {
182 if child.kind() == "command" || child.kind() == "simple_command" {
183 return extract_command_from_node(child, source);
184 }
185 }
186 }
187
188 for child in node.children(&mut cursor) {
190 if child.kind() == "command_name" {
191 if let Ok(arg) = child.utf8_text(source.as_bytes()) {
192 let trimmed = arg.trim();
193 if !trimmed.is_empty() {
194 command.push(trimmed.to_string());
195 }
196 }
197 continue;
198 }
199
200 if matches!(child.kind(), "word" | "string" | "simple_expansion" | "variable_expansion") {
201 let text = child.utf8_text(source.as_bytes());
202 if let Ok(arg) = text {
203 let trimmed = arg.trim();
204 if !trimmed.is_empty() {
205 command.push(trimmed.to_string());
206 }
207 }
208 }
209 }
210
211 if command.is_empty() { None } else { Some(command) }
212}
213
214fn parse_with_basic_tokenization(script: &str) -> Result<Vec<Vec<String>>, String> {
216 let mut commands = Vec::new();
217 let mut current_command = String::new();
218 let mut in_quotes = false;
219 let mut quote_char = ' ';
220 let mut escaped = false;
221
222 for ch in script.chars() {
223 if escaped {
224 current_command.push(ch);
225 escaped = false;
226 continue;
227 }
228
229 match ch {
230 '\\' => {
231 escaped = true;
232 }
233 '\'' | '"' if !in_quotes => {
234 in_quotes = true;
235 quote_char = ch;
236 }
237 c if c == quote_char && in_quotes => {
238 in_quotes = false;
239 }
240 '&' | '|' | ';' if !in_quotes => {
241 if !current_command.trim().is_empty()
242 && let Ok(cmd) = tokenize_command(¤t_command)
243 {
244 commands.push(cmd);
245 }
246 current_command.clear();
247 }
248 _ => current_command.push(ch),
249 }
250 }
251
252 if !current_command.trim().is_empty()
253 && let Ok(cmd) = tokenize_command(¤t_command)
254 {
255 commands.push(cmd);
256 }
257
258 Ok(commands)
259}
260
261fn tokenize_command(cmd: &str) -> Result<Vec<String>, String> {
264 shell_words::split(cmd).map_err(|err| format!("failed to tokenize command: {err}"))
265}
266
267pub fn parse_bash_lc_commands(command: &[String]) -> Option<Vec<Vec<String>>> {
275 if command.is_empty() {
276 return None;
277 }
278
279 let cmd_name = command[0].as_str();
280 let base_cmd = std::path::Path::new(cmd_name)
281 .file_name()
282 .and_then(|osstr| osstr.to_str())
283 .unwrap_or("");
284
285 if base_cmd != "bash" && base_cmd != "zsh" && base_cmd != "sh" {
286 return None;
287 }
288
289 for window in command.windows(2) {
291 if matches!(window[0].as_str(), "-lc" | "-c" | "-il" | "-ic") {
292 let script = &window[1];
293 return parse_shell_commands(script).ok();
294 }
295 }
296
297 None
298}
299
300#[cfg(test)]
301mod tests {
302 use super::*;
303
304 #[test]
305 fn tokenize_simple_command() {
306 let cmd = "git status";
307 let tokens = tokenize_command(cmd).unwrap();
308 assert_eq!(tokens, vec!["git", "status"]);
309 }
310
311 #[test]
312 fn tokenize_quoted_arguments() {
313 let cmd = r#"echo "hello world""#;
314 let tokens = tokenize_command(cmd).unwrap();
315 assert_eq!(tokens, vec!["echo", "hello world"]);
316 }
317
318 #[test]
319 fn parse_single_command() {
320 let script = "git status";
321 let commands = parse_shell_commands(script).unwrap();
322 assert_eq!(commands.len(), 1);
323 assert_eq!(commands[0][0], "git");
324 }
325
326 #[test]
327 fn parse_chained_commands_with_and() {
328 let script = "git status && cargo check";
329 let commands = parse_shell_commands(script).unwrap();
330 assert_eq!(commands.len(), 2);
331 assert_eq!(commands[0][0], "git");
332 assert_eq!(commands[1][0], "cargo");
333 }
334
335 #[test]
336 fn parse_loop_body_commands() {
337 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";
338 let commands = parse_shell_commands(script).unwrap();
339
340 assert_eq!(commands[0], vec!["cd", "crates/codegen/vtcode-core/src/tools/registry"]);
341 assert!(
342 commands
343 .iter()
344 .any(|command| command.first().is_some_and(|name| name == "echo"))
345 );
346 assert!(
347 commands
348 .iter()
349 .any(|command| command.first().is_some_and(|name| name == "grep"))
350 );
351 assert!(
352 commands
353 .iter()
354 .any(|command| command.first().is_some_and(|name| name == "head"))
355 );
356 }
357
358 #[test]
359 fn parse_chained_commands_with_semicolon() {
360 let script = "git status; cargo check";
361 let commands = parse_shell_commands(script).unwrap();
362 assert_eq!(commands.len(), 2);
363 }
364
365 #[test]
366 fn parse_bash_lc_git_status() {
367 let cmd = vec!["bash".to_string(), "-lc".to_string(), "git status".to_string()];
368 let commands = parse_bash_lc_commands(&cmd);
369 assert!(commands.is_some());
370 let commands = commands.unwrap();
371 assert_eq!(commands.len(), 1);
372 assert_eq!(commands[0][0], "git");
373 }
374
375 #[test]
376 fn parse_bash_lc_chained() {
377 let cmd = vec![
378 "bash".to_string(),
379 "-lc".to_string(),
380 "git status && cargo check".to_string(),
381 ];
382 let commands = parse_bash_lc_commands(&cmd);
383 assert!(commands.is_some());
384 let commands = commands.unwrap();
385 assert_eq!(commands.len(), 2);
386 }
387
388 #[test]
389 fn parse_non_bash_command_returns_none() {
390 let cmd = vec!["echo".to_string(), "hello".to_string()];
391 let commands = parse_bash_lc_commands(&cmd);
392 assert!(commands.is_none());
393 }
394
395 #[test]
396 fn parse_bash_without_lc_returns_none() {
397 let cmd = vec!["bash".to_string(), "script.sh".to_string()];
398 let commands = parse_bash_lc_commands(&cmd);
399 assert!(commands.is_none());
400 }
401
402 #[test]
405 fn parse_complex_pipeline() {
406 let script = "cat file.txt | grep -i pattern | sort";
407 let commands = parse_shell_commands(script).unwrap();
408 assert!(!commands.is_empty());
409 }
410
411 #[test]
412 fn parse_with_pipes_and_redirects() {
413 let script = "ls -la | grep file > output.txt";
414 let commands = parse_shell_commands(script).unwrap();
415 assert!(!commands.is_empty());
416 }
417
418 #[test]
419 fn parse_command_substitution_fallback() {
420 let script = "echo $(git status)";
421 let commands = parse_shell_commands(script).unwrap();
422 assert!(!commands.is_empty());
423 }
424
425 #[test]
426 fn parse_escaped_quotes() {
427 let script = r#"echo "hello \"world\"""#;
428 let commands = parse_shell_commands(script).unwrap();
429 assert!(!commands.is_empty());
430 }
431
432 #[test]
433 fn parse_tree_sitter_preserves_command_name_with_quoted_args() {
434 let script = r#"echo "fish and chips""#;
435 let commands = parse_shell_commands_tree_sitter(script).unwrap();
436 assert!(!commands.is_empty());
437 assert_eq!(commands[0][0], "echo");
438 }
439
440 #[test]
441 fn parse_bash_lc_with_pipe() {
442 let cmd = vec!["bash".to_string(), "-lc".to_string(), "ls -la | head -5".to_string()];
443 let commands = parse_bash_lc_commands(&cmd);
444 assert!(commands.is_some());
445 let cmds = commands.unwrap();
446 assert!(!cmds.is_empty());
447 }
448
449 #[test]
450 fn parse_dangerous_shell_command() {
451 let script = "rm -rf /; echo done";
452 let commands = parse_shell_commands(script).unwrap();
453 assert_eq!(commands.len(), 2);
454 assert_eq!(commands[0][0], "rm");
455 }
456
457 #[test]
458 fn prewarm_bash_parser_initializes_successfully() {
459 prewarm_bash_parser().expect("bash parser should initialize");
460 }
461
462 #[test]
463 fn dynamic_find_syntax_is_detected_without_rejecting_quoted_globs() {
464 assert!(contains_dynamic_find_syntax("find src -maxdepth 0 -exe$''c touch /tmp/VT_BYPASS_POC {} +"));
465 assert!(!contains_dynamic_find_syntax("find src -type f -name '*.rs'"));
466 }
467}
468
469use anyhow::{Result, bail};
472
473#[derive(Clone, Copy, Eq, PartialEq)]
475enum QuoteState {
476 None,
477 Single,
478 Double,
479}
480
481pub(crate) fn split_shell_segments(command: &str) -> Result<Vec<String>> {
484 let mut segments = Vec::new();
485 let mut state = QuoteState::None;
486 let mut escaped = false;
487 let mut segment_start = 0usize;
488 let mut chars = command.char_indices().peekable();
489
490 while let Some((idx, ch)) = chars.next() {
491 match state {
492 QuoteState::Single => {
493 if ch == '\'' {
494 state = QuoteState::None;
495 }
496 }
497 QuoteState::Double => {
498 if escaped {
499 escaped = false;
500 continue;
501 }
502
503 match ch {
504 '\\' => escaped = true,
505 '"' => state = QuoteState::None,
506 '`' => bail!("Command injection pattern detected"),
507 '$' if matches!(chars.peek(), Some((_, '('))) => {
508 bail!("Command injection pattern detected");
509 }
510 _ => {}
511 }
512 }
513 QuoteState::None => {
514 if escaped {
515 escaped = false;
516 continue;
517 }
518
519 match ch {
520 '\\' => escaped = true,
521 '\'' => state = QuoteState::Single,
522 '"' => state = QuoteState::Double,
523 '`' => bail!("Command injection pattern detected"),
524 '$' if matches!(chars.peek(), Some((_, '('))) => {
525 bail!("Command injection pattern detected");
526 }
527 ';' => bail!("Unquoted command chaining detected"),
528 '\n' => bail!("Command injection pattern detected"),
529 '|' | '&' => {
530 push_segment(command, segment_start, idx, &mut segments);
531 segment_start = idx + ch.len_utf8();
532 if let Some((next_idx, next_ch)) = chars.peek().copied()
533 && next_ch == ch
534 {
535 chars.next();
536 segment_start = next_idx + next_ch.len_utf8();
537 }
538 }
539 _ => {}
540 }
541 }
542 }
543 }
544
545 push_segment(command, segment_start, command.len(), &mut segments);
546 Ok(segments)
547}
548
549fn push_segment(command: &str, start: usize, end: usize, segments: &mut Vec<String>) {
550 let segment = command[start..end].trim();
551 if !segment.is_empty() {
552 segments.push(segment.to_string());
553 }
554}
555
556pub(crate) fn additional_dangerous_pattern(segment: &str) -> Option<&'static str> {
558 let segment_lower = segment.to_ascii_lowercase();
559 if segment_lower.starts_with(":(){:|:&};:") {
560 return Some(":(){:|:&};:");
561 }
562
563 let tokens =
564 shell_words::split(segment).unwrap_or_else(|_| segment.split_whitespace().map(ToString::to_string).collect());
565 let first = tokens.first()?;
566 let command_name = base_command_name(strip_wrapping_quotes(first)).to_ascii_lowercase();
567
568 match command_name.as_str() {
569 "rmdir" => Some("rmdir"),
570 "wget" => Some("wget"),
571 "curl" => Some("curl"),
572 "chmod" if tokens.iter().skip(1).any(|arg| strip_wrapping_quotes(arg).starts_with("777")) => Some("chmod 777"),
573 "chown"
574 if tokens.iter().skip(1).any(|arg| {
575 let arg = strip_wrapping_quotes(arg).to_ascii_lowercase();
576 arg == "root" || arg.starts_with("root:")
577 }) =>
578 {
579 Some("chown root")
580 }
581 _ => None,
582 }
583}
584
585fn strip_wrapping_quotes(token: &str) -> &str {
586 token
587 .strip_prefix('\'')
588 .and_then(|token| token.strip_suffix('\''))
589 .or_else(|| token.strip_prefix('"').and_then(|token| token.strip_suffix('"')))
590 .unwrap_or(token)
591}
592
593fn base_command_name(command: &str) -> &str {
594 std::path::Path::new(command)
595 .file_name()
596 .and_then(|name| name.to_str())
597 .unwrap_or(command)
598}