Skip to main content

vtcode_safety/command_safety/
shell_parser.rs

1#![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
7//! Shell script parser for `bash -lc` and similar commands.
8//!
9//! This module parses shell commands like:
10//! ```sh
11//! bash -lc "git status && cargo check"
12//! ```
13//!
14//! Into individual command vectors for independent safety checking:
15//! ```text
16//! [["git", "status"], ["cargo", "check"]]
17//! ```
18//!
19//! **Phase 4 Implementation**: Uses tree-sitter for accurate bash AST parsing.
20//! Falls back to basic tokenization for minimal shell syntax.
21
22use std::sync::Mutex;
23use std::sync::OnceLock;
24
25/// Lazy-initialized tree-sitter bash parser (wrapped in Mutex for mutation)
26static BASH_PARSER: OnceLock<Result<Mutex<tree_sitter::Parser>, String>> = OnceLock::new();
27
28/// Returns whether a shell command contains syntax whose meaning depends on
29/// shell expansion rather than the literal argument text.
30///
31/// Safety-sensitive classification must only operate on static command
32/// shapes. Parameter expansion, command substitution, brace expansion,
33/// globbing, and backslash escapes can otherwise turn a harmless-looking token
34/// into a different executable argument at runtime.
35pub 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
67/// Returns whether a `find` command contains shell syntax that can change the
68/// literal option tokens after approval-time tokenization.
69pub 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    // Be conservative when the grammar cannot identify the command shape: a
83    // raw script containing a find invocation and dynamic syntax must not pass
84    // preflight just because parsing was incomplete.
85    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
92/// Gets or initializes the bash parser
93fn 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
107/// Ensures the bash tree-sitter parser is initialized.
108pub fn prewarm_bash_parser() -> Result<(), String> {
109    let _ = get_bash_parser()?;
110    Ok(())
111}
112
113/// Parses a shell script into individual commands using tree-sitter bash grammar
114///
115/// # Example
116/// ```text
117/// Input:  "git status && cargo check"
118/// Output: Ok([["git", "status"], ["cargo", "check"]])
119/// ```
120///
121/// # Fallback
122/// If tree-sitter parsing fails, falls back to simple tokenization
123pub fn parse_shell_commands(script: &str) -> Result<Vec<Vec<String>>, String> {
124    // Try tree-sitter parsing first
125    match parse_with_tree_sitter(script) {
126        Ok(commands) if !commands.is_empty() => return Ok(commands),
127        Ok(_) => {} // Empty result, fall through to basic parsing
128        Err(e) => {
129            tracing::debug!("Tree-sitter bash parsing failed: {}, falling back to basic tokenization", e);
130        }
131    }
132
133    // Fallback to simple tokenization
134    parse_with_basic_tokenization(script)
135}
136
137/// Parses a shell script using tree-sitter bash grammar only (no fallback tokenization).
138///
139/// Use this when caller behavior must be strictly gated on bash grammar validity.
140pub fn parse_shell_commands_tree_sitter(script: &str) -> Result<Vec<Vec<String>>, String> {
141    parse_with_tree_sitter(script)
142}
143
144/// Parses shell script using tree-sitter bash grammar
145fn 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    // Walk the full tree so commands inside loops/conditionals are remembered
155    // for approval and checked for safety.  Top-level-only extraction misses
156    // common read loops such as `for f in ...; do grep ...; done`.
157    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
180/// Extracts a command vector from a tree-sitter node
181fn 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    // For pipeline nodes, extract the first command in the pipeline
186    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    // Extract arguments from command node
195    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
220/// Fallback: Parses shell script with simple tokenization
221fn 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(&current_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(&current_command)
260    {
261        commands.push(cmd);
262    }
263
264    Ok(commands)
265}
266
267/// Splits a command string into arguments
268/// Respects quoted strings and escapes
269fn tokenize_command(cmd: &str) -> Result<Vec<String>, String> {
270    shell_words::split(cmd).map_err(|err| format!("failed to tokenize command: {err}"))
271}
272
273/// Parses `bash -lc "script"` style invocations
274///
275/// # Example
276/// ```text
277/// Input:  vec!["bash", "-lc", "git status && rm /"]
278/// Output: Some([["git", "status"], ["rm", "/"]])
279/// ```
280pub 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    // Look for -lc or -c pattern
296    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    // Phase 4 tests: Tree-sitter based parsing
409
410    #[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
475// === Injection detection (moved from tools::validation::commands) ===
476
477use anyhow::{Result, bail};
478
479/// Quote state for shell segment splitting.
480#[derive(Clone, Copy, Eq, PartialEq)]
481enum QuoteState {
482    None,
483    Single,
484    Double,
485}
486
487/// Split a shell command into segments on unquoted `|` and `&` boundaries,
488/// while detecting injection patterns (`;`, backticks, `$()`, newlines).
489pub(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
562/// Check for additional dangerous patterns not covered by the central dangerous-command detector.
563pub(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}