Skip to main content

vtcode_safety/command_safety/
shell_parser.rs

1//! Shell script parser for `bash -lc` and similar commands.
2//!
3//! This module parses shell commands like:
4//! ```sh
5//! bash -lc "git status && cargo check"
6//! ```
7//!
8//! Into individual command vectors for independent safety checking:
9//! ```text
10//! [["git", "status"], ["cargo", "check"]]
11//! ```
12//!
13//! **Phase 4 Implementation**: Uses tree-sitter for accurate bash AST parsing.
14//! Falls back to basic tokenization for minimal shell syntax.
15
16use std::sync::Mutex;
17use std::sync::OnceLock;
18
19/// Lazy-initialized tree-sitter bash parser (wrapped in Mutex for mutation)
20static BASH_PARSER: OnceLock<Result<Mutex<tree_sitter::Parser>, String>> = OnceLock::new();
21
22/// Gets or initializes the bash parser
23fn 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
37/// Ensures the bash tree-sitter parser is initialized.
38pub fn prewarm_bash_parser() -> Result<(), String> {
39    let _ = get_bash_parser()?;
40    Ok(())
41}
42
43/// Parses a shell script into individual commands using tree-sitter bash grammar
44///
45/// # Example
46/// ```text
47/// Input:  "git status && cargo check"
48/// Output: Ok([["git", "status"], ["cargo", "check"]])
49/// ```
50///
51/// # Fallback
52/// If tree-sitter parsing fails, falls back to simple tokenization
53pub fn parse_shell_commands(script: &str) -> Result<Vec<Vec<String>>, String> {
54    // Try tree-sitter parsing first
55    match parse_with_tree_sitter(script) {
56        Ok(commands) if !commands.is_empty() => return Ok(commands),
57        Ok(_) => {} // Empty result, fall through to basic parsing
58        Err(e) => {
59            tracing::debug!("Tree-sitter bash parsing failed: {}, falling back to basic tokenization", e);
60        }
61    }
62
63    // Fallback to simple tokenization
64    parse_with_basic_tokenization(script)
65}
66
67/// Parses a shell script using tree-sitter bash grammar only (no fallback tokenization).
68///
69/// Use this when caller behavior must be strictly gated on bash grammar validity.
70pub fn parse_shell_commands_tree_sitter(script: &str) -> Result<Vec<Vec<String>>, String> {
71    parse_with_tree_sitter(script)
72}
73
74/// Parses shell script using tree-sitter bash grammar
75fn 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    // Walk the full tree so commands inside loops/conditionals are remembered
85    // for approval and checked for safety.  Top-level-only extraction misses
86    // common read loops such as `for f in ...; do grep ...; done`.
87    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
110/// Extracts a command vector from a tree-sitter node
111fn 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    // For pipeline nodes, extract the first command in the pipeline
116    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    // Extract arguments from command node
125    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
150/// Fallback: Parses shell script with simple tokenization
151fn 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(&current_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(&current_command)
190    {
191        commands.push(cmd);
192    }
193
194    Ok(commands)
195}
196
197/// Splits a command string into arguments
198/// Respects quoted strings and escapes
199fn tokenize_command(cmd: &str) -> Result<Vec<String>, String> {
200    shell_words::split(cmd).map_err(|err| format!("failed to tokenize command: {err}"))
201}
202
203/// Parses `bash -lc "script"` style invocations
204///
205/// # Example
206/// ```text
207/// Input:  vec!["bash", "-lc", "git status && rm /"]
208/// Output: Some([["git", "status"], ["rm", "/"]])
209/// ```
210pub 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    // Look for -lc or -c pattern
226    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    // Phase 4 tests: Tree-sitter based parsing
339
340    #[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
399// === Injection detection (moved from tools::validation::commands) ===
400
401use anyhow::{Result, bail};
402
403/// Quote state for shell segment splitting.
404#[derive(Clone, Copy, Eq, PartialEq)]
405enum QuoteState {
406    None,
407    Single,
408    Double,
409}
410
411/// Split a shell command into segments on unquoted `|` and `&` boundaries,
412/// while detecting injection patterns (`;`, backticks, `$()`, newlines).
413pub(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
486/// Check for additional dangerous patterns not covered by the central dangerous-command detector.
487pub(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}