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/// Returns whether a shell command contains syntax whose meaning depends on
23/// shell expansion rather than the literal argument text.
24///
25/// Safety-sensitive classification must only operate on static command
26/// shapes. Parameter expansion, command substitution, brace expansion,
27/// globbing, and backslash escapes can otherwise turn a harmless-looking token
28/// into a different executable argument at runtime.
29pub 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
61/// Returns whether a `find` command contains shell syntax that can change the
62/// literal option tokens after approval-time tokenization.
63pub 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    // Be conservative when the grammar cannot identify the command shape: a
77    // raw script containing a find invocation and dynamic syntax must not pass
78    // preflight just because parsing was incomplete.
79    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
86/// Gets or initializes the bash parser
87fn 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
101/// Ensures the bash tree-sitter parser is initialized.
102pub fn prewarm_bash_parser() -> Result<(), String> {
103    let _ = get_bash_parser()?;
104    Ok(())
105}
106
107/// Parses a shell script into individual commands using tree-sitter bash grammar
108///
109/// # Example
110/// ```text
111/// Input:  "git status && cargo check"
112/// Output: Ok([["git", "status"], ["cargo", "check"]])
113/// ```
114///
115/// # Fallback
116/// If tree-sitter parsing fails, falls back to simple tokenization
117pub fn parse_shell_commands(script: &str) -> Result<Vec<Vec<String>>, String> {
118    // Try tree-sitter parsing first
119    match parse_with_tree_sitter(script) {
120        Ok(commands) if !commands.is_empty() => return Ok(commands),
121        Ok(_) => {} // Empty result, fall through to basic parsing
122        Err(e) => {
123            tracing::debug!("Tree-sitter bash parsing failed: {}, falling back to basic tokenization", e);
124        }
125    }
126
127    // Fallback to simple tokenization
128    parse_with_basic_tokenization(script)
129}
130
131/// Parses a shell script using tree-sitter bash grammar only (no fallback tokenization).
132///
133/// Use this when caller behavior must be strictly gated on bash grammar validity.
134pub fn parse_shell_commands_tree_sitter(script: &str) -> Result<Vec<Vec<String>>, String> {
135    parse_with_tree_sitter(script)
136}
137
138/// Parses shell script using tree-sitter bash grammar
139fn 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    // Walk the full tree so commands inside loops/conditionals are remembered
149    // for approval and checked for safety.  Top-level-only extraction misses
150    // common read loops such as `for f in ...; do grep ...; done`.
151    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
174/// Extracts a command vector from a tree-sitter node
175fn 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    // For pipeline nodes, extract the first command in the pipeline
180    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    // Extract arguments from command node
189    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
214/// Fallback: Parses shell script with simple tokenization
215fn 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(&current_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(&current_command)
254    {
255        commands.push(cmd);
256    }
257
258    Ok(commands)
259}
260
261/// Splits a command string into arguments
262/// Respects quoted strings and escapes
263fn tokenize_command(cmd: &str) -> Result<Vec<String>, String> {
264    shell_words::split(cmd).map_err(|err| format!("failed to tokenize command: {err}"))
265}
266
267/// Parses `bash -lc "script"` style invocations
268///
269/// # Example
270/// ```text
271/// Input:  vec!["bash", "-lc", "git status && rm /"]
272/// Output: Some([["git", "status"], ["rm", "/"]])
273/// ```
274pub 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    // Look for -lc or -c pattern
290    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    // Phase 4 tests: Tree-sitter based parsing
403
404    #[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
469// === Injection detection (moved from tools::validation::commands) ===
470
471use anyhow::{Result, bail};
472
473/// Quote state for shell segment splitting.
474#[derive(Clone, Copy, Eq, PartialEq)]
475enum QuoteState {
476    None,
477    Single,
478    Double,
479}
480
481/// Split a shell command into segments on unquoted `|` and `&` boundaries,
482/// while detecting injection patterns (`;`, backticks, `$()`, newlines).
483pub(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
556/// Check for additional dangerous patterns not covered by the central dangerous-command detector.
557pub(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}