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
22fn get_bash_parser() -> Result<&'static Mutex<tree_sitter::Parser>, String> {
24 BASH_PARSER
25 .get_or_init(|| {
26 let mut parser = tree_sitter::Parser::new();
27 let lang: tree_sitter::Language = tree_sitter_bash::LANGUAGE.into();
28 parser
29 .set_language(&lang)
30 .map_err(|e| format!("Failed to load bash grammar: {e}"))?;
31 Ok(Mutex::new(parser))
32 })
33 .as_ref()
34 .map_err(Clone::clone)
35}
36
37pub fn prewarm_bash_parser() -> Result<(), String> {
39 let _ = get_bash_parser()?;
40 Ok(())
41}
42
43pub fn parse_shell_commands(script: &str) -> Result<Vec<Vec<String>>, String> {
54 match parse_with_tree_sitter(script) {
56 Ok(commands) if !commands.is_empty() => return Ok(commands),
57 Ok(_) => {} Err(e) => {
59 tracing::debug!("Tree-sitter bash parsing failed: {}, falling back to basic tokenization", e);
60 }
61 }
62
63 parse_with_basic_tokenization(script)
65}
66
67pub fn parse_shell_commands_tree_sitter(script: &str) -> Result<Vec<Vec<String>>, String> {
71 parse_with_tree_sitter(script)
72}
73
74fn parse_with_tree_sitter(script: &str) -> Result<Vec<Vec<String>>, String> {
76 let parser_guard = get_bash_parser()?;
77 let mut parser = parser_guard.lock().map_err(|e| format!("Failed to lock parser: {e}"))?;
78
79 let tree = parser.parse(script, None).ok_or_else(|| "Failed to parse script".to_string())?;
80
81 let mut commands = Vec::new();
82 let root = tree.root_node();
83
84 collect_commands_from_node(root, script, &mut commands);
88
89 Ok(commands)
90}
91
92fn collect_commands_from_node(node: tree_sitter::Node, source: &str, commands: &mut Vec<Vec<String>>) {
93 match node.kind() {
94 "command" | "simple_command" => {
95 if let Some(cmd) = extract_command_from_node(node, source)
96 && !cmd.is_empty()
97 {
98 commands.push(cmd);
99 }
100 }
101 _ => {
102 let mut cursor = node.walk();
103 for child in node.children(&mut cursor) {
104 collect_commands_from_node(child, source, commands);
105 }
106 }
107 }
108}
109
110fn extract_command_from_node(node: tree_sitter::Node, source: &str) -> Option<Vec<String>> {
112 let mut command = Vec::new();
113 let mut cursor = node.walk();
114
115 if node.kind() == "pipeline" {
117 for child in node.children(&mut cursor) {
118 if child.kind() == "command" || child.kind() == "simple_command" {
119 return extract_command_from_node(child, source);
120 }
121 }
122 }
123
124 for child in node.children(&mut cursor) {
126 if child.kind() == "command_name" {
127 if let Ok(arg) = child.utf8_text(source.as_bytes()) {
128 let trimmed = arg.trim();
129 if !trimmed.is_empty() {
130 command.push(trimmed.to_string());
131 }
132 }
133 continue;
134 }
135
136 if matches!(child.kind(), "word" | "string" | "simple_expansion" | "variable_expansion") {
137 let text = child.utf8_text(source.as_bytes());
138 if let Ok(arg) = text {
139 let trimmed = arg.trim();
140 if !trimmed.is_empty() {
141 command.push(trimmed.to_string());
142 }
143 }
144 }
145 }
146
147 if command.is_empty() { None } else { Some(command) }
148}
149
150fn parse_with_basic_tokenization(script: &str) -> Result<Vec<Vec<String>>, String> {
152 let mut commands = Vec::new();
153 let mut current_command = String::new();
154 let mut in_quotes = false;
155 let mut quote_char = ' ';
156 let mut escaped = false;
157
158 for ch in script.chars() {
159 if escaped {
160 current_command.push(ch);
161 escaped = false;
162 continue;
163 }
164
165 match ch {
166 '\\' => {
167 escaped = true;
168 }
169 '\'' | '"' if !in_quotes => {
170 in_quotes = true;
171 quote_char = ch;
172 }
173 c if c == quote_char && in_quotes => {
174 in_quotes = false;
175 }
176 '&' | '|' | ';' if !in_quotes => {
177 if !current_command.trim().is_empty()
178 && let Ok(cmd) = tokenize_command(¤t_command)
179 {
180 commands.push(cmd);
181 }
182 current_command.clear();
183 }
184 _ => current_command.push(ch),
185 }
186 }
187
188 if !current_command.trim().is_empty()
189 && let Ok(cmd) = tokenize_command(¤t_command)
190 {
191 commands.push(cmd);
192 }
193
194 Ok(commands)
195}
196
197fn tokenize_command(cmd: &str) -> Result<Vec<String>, String> {
200 shell_words::split(cmd).map_err(|err| format!("failed to tokenize command: {err}"))
201}
202
203pub fn parse_bash_lc_commands(command: &[String]) -> Option<Vec<Vec<String>>> {
211 if command.is_empty() {
212 return None;
213 }
214
215 let cmd_name = command[0].as_str();
216 let base_cmd = std::path::Path::new(cmd_name)
217 .file_name()
218 .and_then(|osstr| osstr.to_str())
219 .unwrap_or("");
220
221 if base_cmd != "bash" && base_cmd != "zsh" && base_cmd != "sh" {
222 return None;
223 }
224
225 for window in command.windows(2) {
227 if matches!(window[0].as_str(), "-lc" | "-c" | "-il" | "-ic") {
228 let script = &window[1];
229 return parse_shell_commands(script).ok();
230 }
231 }
232
233 None
234}
235
236#[cfg(test)]
237mod tests {
238 use super::*;
239
240 #[test]
241 fn tokenize_simple_command() {
242 let cmd = "git status";
243 let tokens = tokenize_command(cmd).unwrap();
244 assert_eq!(tokens, vec!["git", "status"]);
245 }
246
247 #[test]
248 fn tokenize_quoted_arguments() {
249 let cmd = r#"echo "hello world""#;
250 let tokens = tokenize_command(cmd).unwrap();
251 assert_eq!(tokens, vec!["echo", "hello world"]);
252 }
253
254 #[test]
255 fn parse_single_command() {
256 let script = "git status";
257 let commands = parse_shell_commands(script).unwrap();
258 assert_eq!(commands.len(), 1);
259 assert_eq!(commands[0][0], "git");
260 }
261
262 #[test]
263 fn parse_chained_commands_with_and() {
264 let script = "git status && cargo check";
265 let commands = parse_shell_commands(script).unwrap();
266 assert_eq!(commands.len(), 2);
267 assert_eq!(commands[0][0], "git");
268 assert_eq!(commands[1][0], "cargo");
269 }
270
271 #[test]
272 fn parse_loop_body_commands() {
273 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";
274 let commands = parse_shell_commands(script).unwrap();
275
276 assert_eq!(commands[0], vec!["cd", "crates/codegen/vtcode-core/src/tools/registry"]);
277 assert!(
278 commands
279 .iter()
280 .any(|command| command.first().is_some_and(|name| name == "echo"))
281 );
282 assert!(
283 commands
284 .iter()
285 .any(|command| command.first().is_some_and(|name| name == "grep"))
286 );
287 assert!(
288 commands
289 .iter()
290 .any(|command| command.first().is_some_and(|name| name == "head"))
291 );
292 }
293
294 #[test]
295 fn parse_chained_commands_with_semicolon() {
296 let script = "git status; cargo check";
297 let commands = parse_shell_commands(script).unwrap();
298 assert_eq!(commands.len(), 2);
299 }
300
301 #[test]
302 fn parse_bash_lc_git_status() {
303 let cmd = vec!["bash".to_string(), "-lc".to_string(), "git status".to_string()];
304 let commands = parse_bash_lc_commands(&cmd);
305 assert!(commands.is_some());
306 let commands = commands.unwrap();
307 assert_eq!(commands.len(), 1);
308 assert_eq!(commands[0][0], "git");
309 }
310
311 #[test]
312 fn parse_bash_lc_chained() {
313 let cmd = vec![
314 "bash".to_string(),
315 "-lc".to_string(),
316 "git status && cargo check".to_string(),
317 ];
318 let commands = parse_bash_lc_commands(&cmd);
319 assert!(commands.is_some());
320 let commands = commands.unwrap();
321 assert_eq!(commands.len(), 2);
322 }
323
324 #[test]
325 fn parse_non_bash_command_returns_none() {
326 let cmd = vec!["echo".to_string(), "hello".to_string()];
327 let commands = parse_bash_lc_commands(&cmd);
328 assert!(commands.is_none());
329 }
330
331 #[test]
332 fn parse_bash_without_lc_returns_none() {
333 let cmd = vec!["bash".to_string(), "script.sh".to_string()];
334 let commands = parse_bash_lc_commands(&cmd);
335 assert!(commands.is_none());
336 }
337
338 #[test]
341 fn parse_complex_pipeline() {
342 let script = "cat file.txt | grep -i pattern | sort";
343 let commands = parse_shell_commands(script).unwrap();
344 assert!(!commands.is_empty());
345 }
346
347 #[test]
348 fn parse_with_pipes_and_redirects() {
349 let script = "ls -la | grep file > output.txt";
350 let commands = parse_shell_commands(script).unwrap();
351 assert!(!commands.is_empty());
352 }
353
354 #[test]
355 fn parse_command_substitution_fallback() {
356 let script = "echo $(git status)";
357 let commands = parse_shell_commands(script).unwrap();
358 assert!(!commands.is_empty());
359 }
360
361 #[test]
362 fn parse_escaped_quotes() {
363 let script = r#"echo "hello \"world\"""#;
364 let commands = parse_shell_commands(script).unwrap();
365 assert!(!commands.is_empty());
366 }
367
368 #[test]
369 fn parse_tree_sitter_preserves_command_name_with_quoted_args() {
370 let script = r#"echo "fish and chips""#;
371 let commands = parse_shell_commands_tree_sitter(script).unwrap();
372 assert!(!commands.is_empty());
373 assert_eq!(commands[0][0], "echo");
374 }
375
376 #[test]
377 fn parse_bash_lc_with_pipe() {
378 let cmd = vec!["bash".to_string(), "-lc".to_string(), "ls -la | head -5".to_string()];
379 let commands = parse_bash_lc_commands(&cmd);
380 assert!(commands.is_some());
381 let cmds = commands.unwrap();
382 assert!(!cmds.is_empty());
383 }
384
385 #[test]
386 fn parse_dangerous_shell_command() {
387 let script = "rm -rf /; echo done";
388 let commands = parse_shell_commands(script).unwrap();
389 assert_eq!(commands.len(), 2);
390 assert_eq!(commands[0][0], "rm");
391 }
392
393 #[test]
394 fn prewarm_bash_parser_initializes_successfully() {
395 prewarm_bash_parser().expect("bash parser should initialize");
396 }
397}
398
399use anyhow::{Result, bail};
402
403#[derive(Clone, Copy, Eq, PartialEq)]
405enum QuoteState {
406 None,
407 Single,
408 Double,
409}
410
411pub(crate) fn split_shell_segments(command: &str) -> Result<Vec<String>> {
414 let mut segments = Vec::new();
415 let mut state = QuoteState::None;
416 let mut escaped = false;
417 let mut segment_start = 0usize;
418 let mut chars = command.char_indices().peekable();
419
420 while let Some((idx, ch)) = chars.next() {
421 match state {
422 QuoteState::Single => {
423 if ch == '\'' {
424 state = QuoteState::None;
425 }
426 }
427 QuoteState::Double => {
428 if escaped {
429 escaped = false;
430 continue;
431 }
432
433 match ch {
434 '\\' => escaped = true,
435 '"' => state = QuoteState::None,
436 '`' => bail!("Command injection pattern detected"),
437 '$' if matches!(chars.peek(), Some((_, '('))) => {
438 bail!("Command injection pattern detected");
439 }
440 _ => {}
441 }
442 }
443 QuoteState::None => {
444 if escaped {
445 escaped = false;
446 continue;
447 }
448
449 match ch {
450 '\\' => escaped = true,
451 '\'' => state = QuoteState::Single,
452 '"' => state = QuoteState::Double,
453 '`' => bail!("Command injection pattern detected"),
454 '$' if matches!(chars.peek(), Some((_, '('))) => {
455 bail!("Command injection pattern detected");
456 }
457 ';' => bail!("Unquoted command chaining detected"),
458 '\n' => bail!("Command injection pattern detected"),
459 '|' | '&' => {
460 push_segment(command, segment_start, idx, &mut segments);
461 segment_start = idx + ch.len_utf8();
462 if let Some((next_idx, next_ch)) = chars.peek().copied()
463 && next_ch == ch
464 {
465 chars.next();
466 segment_start = next_idx + next_ch.len_utf8();
467 }
468 }
469 _ => {}
470 }
471 }
472 }
473 }
474
475 push_segment(command, segment_start, command.len(), &mut segments);
476 Ok(segments)
477}
478
479fn push_segment(command: &str, start: usize, end: usize, segments: &mut Vec<String>) {
480 let segment = command[start..end].trim();
481 if !segment.is_empty() {
482 segments.push(segment.to_string());
483 }
484}
485
486pub(crate) fn additional_dangerous_pattern(segment: &str) -> Option<&'static str> {
488 let segment_lower = segment.to_ascii_lowercase();
489 if segment_lower.starts_with(":(){:|:&};:") {
490 return Some(":(){:|:&};:");
491 }
492
493 let tokens =
494 shell_words::split(segment).unwrap_or_else(|_| segment.split_whitespace().map(ToString::to_string).collect());
495 let first = tokens.first()?;
496 let command_name = base_command_name(strip_wrapping_quotes(first)).to_ascii_lowercase();
497
498 match command_name.as_str() {
499 "rmdir" => Some("rmdir"),
500 "wget" => Some("wget"),
501 "curl" => Some("curl"),
502 "chmod" if tokens.iter().skip(1).any(|arg| strip_wrapping_quotes(arg).starts_with("777")) => Some("chmod 777"),
503 "chown"
504 if tokens.iter().skip(1).any(|arg| {
505 let arg = strip_wrapping_quotes(arg).to_ascii_lowercase();
506 arg == "root" || arg.starts_with("root:")
507 }) =>
508 {
509 Some("chown root")
510 }
511 _ => None,
512 }
513}
514
515fn strip_wrapping_quotes(token: &str) -> &str {
516 token
517 .strip_prefix('\'')
518 .and_then(|token| token.strip_suffix('\''))
519 .or_else(|| token.strip_prefix('"').and_then(|token| token.strip_suffix('"')))
520 .unwrap_or(token)
521}
522
523fn base_command_name(command: &str) -> &str {
524 std::path::Path::new(command)
525 .file_name()
526 .and_then(|name| name.to_str())
527 .unwrap_or(command)
528}