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::num::NonZeroUsize;
23use std::sync::Mutex;
24use std::sync::OnceLock;
25
26use anyhow::Result;
27use lru::LruCache;
28
29/// Lazy-initialized tree-sitter bash parser (wrapped in Mutex for mutation)
30static BASH_PARSER: OnceLock<Result<Mutex<tree_sitter::Parser>, String>> = OnceLock::new();
31
32/// Upper bound on cached parsed bash trees.
33const SHELL_TREE_CACHE_CAPACITY: usize = 256;
34
35/// Process-wide LRU of parsed bash trees keyed by source text.
36///
37/// Command classification parses the *same* static script several times per
38/// validation: `contains_dynamic_find_syntax` (command extraction),
39/// `validate_redirection_paths` (AST walk), and `parse_shell_commands`
40/// (command extraction again) each run the grammar independently. A single
41/// `tree_sitter::Tree` is a cheap refcounted handle (`Clone` bumps a refcount,
42/// not a re-parse), so parsing once and sharing the tree across every pass
43/// removes N-1 tree-sitter runs per command — the same "compute the
44/// representation once" shape that unblocked the symbol-interner storm in the
45/// clippy `nonstandard_macro_braces` fix. The tree is a pure function of the
46/// source text, so caching cannot change any safety verdict; the cache only
47/// ever returns the exact grammar output for the exact input.
48static SHELL_TREE_CACHE: OnceLock<Mutex<LruCache<String, tree_sitter::Tree, rustc_hash::FxBuildHasher>>> =
49    OnceLock::new();
50
51/// Returns the shared parsed-tree cache, initializing it on first use.
52///
53/// A poisoned lock is recovered rather than propagated: a stale cache entry is
54/// a pure performance concern and must never turn into a fail-open safety path
55/// or a hard error on an otherwise classifiable command.
56fn shell_tree_cache() -> &'static Mutex<LruCache<String, tree_sitter::Tree, rustc_hash::FxBuildHasher>> {
57    SHELL_TREE_CACHE.get_or_init(|| {
58        let capacity = NonZeroUsize::new(SHELL_TREE_CACHE_CAPACITY).unwrap_or(NonZeroUsize::MIN);
59        Mutex::new(LruCache::with_hasher(capacity, rustc_hash::FxBuildHasher))
60    })
61}
62
63/// Parses `script` into a tree-sitter bash tree, reusing the cached tree when
64/// the exact source text was parsed before.
65///
66/// Returns the tree together with whether the grammar reported a syntax error,
67/// so callers keep their original `has_error` semantics. Tree-sitter applies
68/// error recovery instead of returning `None`, so the split between "did not
69/// parse at all" (`Err`) and "parsed with errors" (`Ok((.., true))`) is
70/// preserved for callers that must fail closed on malformed input.
71fn parse_bash_tree(script: &str) -> Result<(tree_sitter::Tree, bool), String> {
72    if let Some(tree) = {
73        let mut cache = shell_tree_cache().lock().unwrap_or_else(|poisoned| poisoned.into_inner());
74        cache.get(script).cloned()
75    } {
76        let has_error = tree.root_node().has_error();
77        return Ok((tree, has_error));
78    }
79
80    let (tree, has_error) = {
81        let parser_guard = get_bash_parser()?;
82        let mut parser = parser_guard.lock().map_err(|e| format!("Failed to lock parser: {e}"))?;
83        let tree = parser.parse(script, None).ok_or_else(|| "Failed to parse script".to_string())?;
84        let has_error = tree.root_node().has_error();
85        (tree, has_error)
86    };
87
88    let mut cache = shell_tree_cache().lock().unwrap_or_else(|poisoned| poisoned.into_inner());
89    drop(cache.put(script.to_string(), tree.clone()));
90    Ok((tree, has_error))
91}
92
93/// Returns whether a shell command contains syntax whose meaning depends on
94/// shell expansion rather than the literal argument text.
95///
96/// Safety-sensitive classification must only operate on static command
97/// shapes. Parameter expansion, command substitution, brace expansion,
98/// globbing, and unquoted backslash escapes can otherwise turn a
99/// harmless-looking token into a different executable argument at runtime.
100/// Backslash escapes inside double-quoted arguments are consumed as literal
101/// argument syntax so patterns such as `rg -n "\\[profile"` remain classifiable.
102pub fn contains_dynamic_shell_syntax(command: &str) -> bool {
103    enum ShellQuote {
104        Single,
105        Double,
106    }
107
108    let mut quote: Option<ShellQuote> = None;
109    let mut characters = command.chars();
110
111    while let Some(character) = characters.next() {
112        match quote {
113            Some(ShellQuote::Single) => {
114                if character == '\'' {
115                    quote = None;
116                }
117            }
118            Some(ShellQuote::Double) => match character {
119                '"' => quote = None,
120                '$' | '`' => return true,
121                '\\'
122                    // Backslash escapes inside double quotes are literal
123                    // argument syntax. Consume the escaped character so an
124                    // escaped quote cannot incorrectly end the quoted region;
125                    // unquoted escapes remain rejected below because they can
126                    // alter the command token or its shell structure.
127                    if characters.next().is_none() => {
128                        return true;
129                    }
130                _ => {}
131            },
132            None => match character {
133                '\'' => quote = Some(ShellQuote::Single),
134                '"' => quote = Some(ShellQuote::Double),
135                '\\' | '$' | '`' | '{' | '}' | '*' | '?' | '[' | ']' => return true,
136                _ => {}
137            },
138        }
139    }
140
141    quote.is_some()
142}
143
144/// Returns whether a `find` command contains shell syntax that can change the
145/// literal option tokens after approval-time tokenization.
146pub fn contains_dynamic_find_syntax(script: &str) -> bool {
147    if let Ok(commands) = parse_shell_commands_tree_sitter(script)
148        && commands.iter().any(|command| {
149            command
150                .first()
151                .map(|program| base_command_name(program) == "find")
152                .unwrap_or(false)
153                && command.iter().any(|word| contains_dynamic_shell_syntax(word))
154        })
155    {
156        return true;
157    }
158
159    // Be conservative when the grammar cannot identify the command shape: a
160    // raw script containing a find invocation and dynamic syntax must not pass
161    // preflight just because parsing was incomplete.
162    let has_find_word = script.split_whitespace().any(|word| {
163        let command = word.trim_matches(|character: char| !character.is_ascii_alphanumeric() && character != '/');
164        base_command_name(command) == "find"
165    });
166    has_find_word && contains_dynamic_shell_syntax(script)
167}
168
169/// Gets or initializes the bash parser
170fn get_bash_parser() -> Result<&'static Mutex<tree_sitter::Parser>, String> {
171    BASH_PARSER
172        .get_or_init(|| {
173            let mut parser = tree_sitter::Parser::new();
174            let lang: tree_sitter::Language = tree_sitter_bash::LANGUAGE.into();
175            parser
176                .set_language(&lang)
177                .map_err(|e| format!("Failed to load bash grammar: {e}"))?;
178            Ok(Mutex::new(parser))
179        })
180        .as_ref()
181        .map_err(Clone::clone)
182}
183
184/// Ensures the bash tree-sitter parser is initialized.
185pub fn prewarm_bash_parser() -> Result<(), String> {
186    let _ = get_bash_parser()?;
187    Ok(())
188}
189
190/// Parses a shell script into individual commands using tree-sitter bash grammar
191///
192/// # Example
193/// ```text
194/// Input:  "git status && cargo check"
195/// Output: Ok([["git", "status"], ["cargo", "check"]])
196/// ```
197///
198/// # Fallback
199/// If tree-sitter parsing fails, falls back to simple tokenization
200pub fn parse_shell_commands(script: &str) -> Result<Vec<Vec<String>>, String> {
201    // Try tree-sitter parsing first
202    match parse_with_tree_sitter(script, false) {
203        Ok(commands) if !commands.is_empty() => return Ok(commands),
204        Ok(_) => {} // Empty result, fall through to basic parsing
205        Err(e) => {
206            tracing::debug!("Tree-sitter bash parsing failed: {}, falling back to basic tokenization", e);
207        }
208    }
209
210    // Fallback to simple tokenization
211    parse_with_basic_tokenization(script)
212}
213
214/// Parses a shell script using tree-sitter bash grammar only (no fallback tokenization).
215///
216/// Use this when caller behavior must be strictly gated on bash grammar validity.
217pub fn parse_shell_commands_tree_sitter(script: &str) -> Result<Vec<Vec<String>>, String> {
218    parse_with_tree_sitter(script, true)
219}
220
221/// Returns whether every redirection in a static shell script only routes
222/// command output. Input, heredoc, and descriptor-closing redirections remain
223/// unsupported so progress classification can fail closed.
224pub fn has_only_output_redirections(script: &str) -> bool {
225    if contains_dynamic_shell_syntax(script) {
226        return false;
227    }
228    if contains_background_operator(script) {
229        return false;
230    }
231
232    let Ok((tree, has_error)) = parse_bash_tree(script) else {
233        return false;
234    };
235    if has_error {
236        return false;
237    }
238
239    let mut saw_redirection = false;
240    if !collect_output_redirections(tree.root_node(), script, &mut saw_redirection) {
241        return false;
242    }
243    saw_redirection
244}
245
246/// Validate literal file-redirection targets before command classification drops them.
247/// Descriptor routing carries no path; unresolved shell expansion fails closed.
248pub(crate) fn validate_redirection_paths(script: &str) -> Result<()> {
249    use anyhow::{Context, anyhow, ensure};
250    if !script.contains(['<', '>']) {
251        return Ok(());
252    }
253    let (tree, has_error) =
254        parse_bash_tree(script).map_err(|error| anyhow!("failed to parse shell redirections: {error}"))?;
255    ensure!(!has_error, "cannot validate malformed shell redirections");
256    let mut pending = vec![tree.root_node()];
257    while let Some(node) = pending.pop() {
258        if node.kind() == "file_redirect" {
259            let text = node.utf8_text(script.as_bytes()).context("invalid shell redirection text")?;
260            let operator = text.trim_start_matches(|character: char| character.is_ascii_digit());
261            let descriptor_route = operator.starts_with(">&") || operator.starts_with("<&");
262            let mut cursor = node.walk();
263            let destinations = node.children_by_field_name("destination", &mut cursor);
264            for destination in destinations {
265                let raw = destination
266                    .utf8_text(script.as_bytes())
267                    .context("invalid redirection destination")?;
268                ensure!(!contains_dynamic_shell_syntax(raw), "dynamic redirection destination is not allowed");
269                let words = shell_words::split(raw).context("invalid quoted redirection destination")?;
270                ensure!(words.len() == 1, "redirection destination must be one literal path");
271                let path = words.first().context("missing redirection destination")?;
272                if descriptor_route && (path == "-" || path.chars().all(|character| character.is_ascii_digit())) {
273                    continue;
274                }
275                // `sh` expands a leading `~` after validation, so treating it
276                // as a relative literal would let a redirection escape the
277                // workspace (for example, `> ~/.config`). Require callers to
278                // provide an explicit, policy-checked destination instead.
279                ensure!(!path.starts_with('~'), "home-directory redirection destinations are not allowed");
280                // The null sink is the one intentional device path used by normal commands.
281                if path == "/dev/null" {
282                    continue;
283                }
284                vtcode_commons::paths::validate_path_safety(path)
285                    .with_context(|| format!("unsafe shell redirection destination: {path}"))?;
286            }
287        }
288        let mut cursor = node.walk();
289        pending.extend(node.named_children(&mut cursor));
290    }
291    Ok(())
292}
293
294fn contains_background_operator(script: &str) -> bool {
295    let chars = script.chars().collect::<Vec<_>>();
296    let mut index = 0;
297    let mut in_single_quote = false;
298    let mut in_double_quote = false;
299
300    while index < chars.len() {
301        let character = chars[index];
302        if character == '\'' && !in_double_quote {
303            in_single_quote = !in_single_quote;
304            index += 1;
305            continue;
306        }
307        if character == '"' && !in_single_quote {
308            in_double_quote = !in_double_quote;
309            index += 1;
310            continue;
311        }
312        if in_single_quote || in_double_quote {
313            index += 1;
314            continue;
315        }
316
317        if character == '&' {
318            let previous = index.checked_sub(1).and_then(|position| chars.get(position));
319            let next = chars.get(index + 1);
320            if next == Some(&'&') {
321                index += 2;
322                continue;
323            }
324            let part_of_allowed_operator = next == Some(&'&')
325                || next == Some(&'>')
326                || previous == Some(&'>')
327                || previous == Some(&'|')
328                || previous == Some(&'<');
329            if !part_of_allowed_operator {
330                return true;
331            }
332        }
333        index += 1;
334    }
335
336    false
337}
338
339fn collect_output_redirections(node: tree_sitter::Node, source: &str, saw_redirection: &mut bool) -> bool {
340    match node.kind() {
341        "file_redirect" => {
342            *saw_redirection = true;
343            let Ok(text) = node.utf8_text(source.as_bytes()) else {
344                return false;
345            };
346            if !is_output_redirection(text) {
347                return false;
348            }
349        }
350        "heredoc_redirect" | "herestring_redirect" => return false,
351        _ => {}
352    }
353
354    let mut cursor = node.walk();
355    node.children(&mut cursor)
356        .all(|child| collect_output_redirections(child, source, saw_redirection))
357}
358
359fn is_output_redirection(text: &str) -> bool {
360    let redirect = text.trim_start_matches(|character: char| character.is_ascii_digit());
361    if redirect.starts_with("&>") {
362        return !redirect.starts_with("&>-");
363    }
364    if let Some(destination) = redirect.strip_prefix(">&") {
365        return destination.trim().chars().all(|character| character.is_ascii_digit());
366    }
367
368    redirect.starts_with('>') && !redirect.starts_with(">&-")
369}
370
371/// Parses shell script using tree-sitter bash grammar.
372fn parse_with_tree_sitter(script: &str, reject_syntax_errors: bool) -> Result<Vec<Vec<String>>, String> {
373    let (tree, has_error) = parse_bash_tree(script)?;
374
375    let mut commands = Vec::new();
376    let root = tree.root_node();
377    if reject_syntax_errors && has_error {
378        return Err("Shell script contains syntax errors".to_string());
379    }
380
381    // Walk the full tree so commands inside loops/conditionals are remembered
382    // for approval and checked for safety.  Top-level-only extraction misses
383    // common read loops such as `for f in ...; do grep ...; done`.
384    collect_commands_from_node(root, script, &mut commands);
385
386    Ok(commands)
387}
388
389fn collect_commands_from_node(node: tree_sitter::Node, source: &str, commands: &mut Vec<Vec<String>>) {
390    match node.kind() {
391        "command" | "simple_command" => {
392            if let Some(cmd) = extract_command_from_node(node, source)
393                && !cmd.is_empty()
394            {
395                commands.push(cmd);
396            }
397        }
398        _ => {
399            let mut cursor = node.walk();
400            for child in node.children(&mut cursor) {
401                collect_commands_from_node(child, source, commands);
402            }
403        }
404    }
405}
406
407/// Extracts a command vector from a tree-sitter node
408fn extract_command_from_node(node: tree_sitter::Node, source: &str) -> Option<Vec<String>> {
409    let mut command = Vec::new();
410    let mut cursor = node.walk();
411
412    // For pipeline nodes, extract the first command in the pipeline
413    if node.kind() == "pipeline" {
414        for child in node.children(&mut cursor) {
415            if child.kind() == "command" || child.kind() == "simple_command" {
416                return extract_command_from_node(child, source);
417            }
418        }
419    }
420
421    // Extract arguments from command node
422    for child in node.children(&mut cursor) {
423        if child.kind() == "command_name" {
424            if let Ok(arg) = child.utf8_text(source.as_bytes()) {
425                let trimmed = arg.trim();
426                if !trimmed.is_empty() {
427                    command.push(trimmed.to_string());
428                }
429            }
430            continue;
431        }
432
433        // Leading `KEY=value` prefixes and concatenated assignment values
434        // (`NODE_OPTIONS='--require ./x'`) are part of the command's
435        // environment contract. Keep them as one word so intent/activity
436        // classification can inspect injection keys;
437        // `command_words_after_environment_prefix` still strips them before
438        // locating the executable. `concatenation` must use the full span
439        // text — its children are only the `NAME=` and quoted value pieces.
440        if matches!(child.kind(), "variable_assignment" | "concatenation") {
441            if let Ok(arg) = child.utf8_text(source.as_bytes()) {
442                let trimmed = arg.trim();
443                if !trimmed.is_empty() {
444                    command.push(trimmed.to_string());
445                }
446            }
447            continue;
448        }
449
450        if matches!(
451            child.kind(),
452            "word" | "string" | "raw_string" | "ansi_c_string" | "simple_expansion" | "variable_expansion"
453        ) {
454            let text = child.utf8_text(source.as_bytes());
455            if let Ok(arg) = text {
456                let trimmed = arg.trim();
457                if !trimmed.is_empty() {
458                    command.push(trimmed.to_string());
459                }
460            }
461        }
462    }
463
464    if command.is_empty() { None } else { Some(command) }
465}
466
467/// Fallback: Parses shell script with simple tokenization
468fn parse_with_basic_tokenization(script: &str) -> Result<Vec<Vec<String>>, String> {
469    let mut commands = Vec::new();
470    let mut current_command = String::new();
471    let mut in_quotes = false;
472    let mut quote_char = ' ';
473    let mut escaped = false;
474
475    for ch in script.chars() {
476        if escaped {
477            current_command.push(ch);
478            escaped = false;
479            continue;
480        }
481
482        match ch {
483            '\\' => {
484                escaped = true;
485            }
486            '\'' | '"' if !in_quotes => {
487                in_quotes = true;
488                quote_char = ch;
489            }
490            c if c == quote_char && in_quotes => {
491                in_quotes = false;
492            }
493            '&' | '|' | ';' if !in_quotes => {
494                if !current_command.trim().is_empty()
495                    && let Ok(cmd) = tokenize_command(&current_command)
496                {
497                    commands.push(cmd);
498                }
499                current_command.clear();
500            }
501            _ => current_command.push(ch),
502        }
503    }
504
505    if !current_command.trim().is_empty()
506        && let Ok(cmd) = tokenize_command(&current_command)
507    {
508        commands.push(cmd);
509    }
510
511    Ok(commands)
512}
513
514/// Splits a command string into arguments
515/// Respects quoted strings and escapes
516fn tokenize_command(cmd: &str) -> Result<Vec<String>, String> {
517    shell_words::split(cmd).map_err(|err| format!("failed to tokenize command: {err}"))
518}
519
520/// Parses `bash -lc "script"` style invocations
521///
522/// # Example
523/// ```text
524/// Input:  vec!["bash", "-lc", "git status && rm /"]
525/// Output: Some([["git", "status"], ["rm", "/"]])
526/// ```
527pub fn parse_bash_lc_commands(command: &[String]) -> Option<Vec<Vec<String>>> {
528    if command.is_empty() {
529        return None;
530    }
531
532    let cmd_name = command[0].as_str();
533    let base_cmd = std::path::Path::new(cmd_name)
534        .file_name()
535        .and_then(|osstr| osstr.to_str())
536        .unwrap_or("");
537
538    if base_cmd != "bash" && base_cmd != "zsh" && base_cmd != "sh" {
539        return None;
540    }
541
542    // Look for -lc or -c pattern
543    for window in command.windows(2) {
544        if matches!(window[0].as_str(), "-lc" | "-c" | "-il" | "-ic") {
545            let script = &window[1];
546            return parse_shell_commands(script).ok();
547        }
548    }
549
550    None
551}
552
553#[cfg(test)]
554mod tests {
555    use super::*;
556
557    #[test]
558    fn tokenize_simple_command() {
559        let cmd = "git status";
560        let tokens = tokenize_command(cmd).unwrap();
561        assert_eq!(tokens, vec!["git", "status"]);
562    }
563
564    #[test]
565    fn tokenize_quoted_arguments() {
566        let cmd = r#"echo "hello world""#;
567        let tokens = tokenize_command(cmd).unwrap();
568        assert_eq!(tokens, vec!["echo", "hello world"]);
569    }
570
571    #[test]
572    fn parse_single_command() {
573        let script = "git status";
574        let commands = parse_shell_commands(script).unwrap();
575        assert_eq!(commands.len(), 1);
576        assert_eq!(commands[0][0], "git");
577    }
578
579    #[test]
580    fn parse_chained_commands_with_and() {
581        let script = "git status && cargo check";
582        let commands = parse_shell_commands(script).unwrap();
583        assert_eq!(commands.len(), 2);
584        assert_eq!(commands[0][0], "git");
585        assert_eq!(commands[1][0], "cargo");
586    }
587
588    #[test]
589    fn parse_loop_body_commands() {
590        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";
591        let commands = parse_shell_commands(script).unwrap();
592
593        assert_eq!(commands[0], vec!["cd", "crates/codegen/vtcode-core/src/tools/registry"]);
594        assert!(
595            commands
596                .iter()
597                .any(|command| command.first().is_some_and(|name| name == "echo"))
598        );
599        assert!(
600            commands
601                .iter()
602                .any(|command| command.first().is_some_and(|name| name == "grep"))
603        );
604        assert!(
605            commands
606                .iter()
607                .any(|command| command.first().is_some_and(|name| name == "head"))
608        );
609    }
610
611    #[test]
612    fn parse_chained_commands_with_semicolon() {
613        let script = "git status; cargo check";
614        let commands = parse_shell_commands(script).unwrap();
615        assert_eq!(commands.len(), 2);
616    }
617
618    #[test]
619    fn parse_bash_lc_git_status() {
620        let cmd = vec!["bash".to_string(), "-lc".to_string(), "git status".to_string()];
621        let commands = parse_bash_lc_commands(&cmd);
622        assert!(commands.is_some());
623        let commands = commands.unwrap();
624        assert_eq!(commands.len(), 1);
625        assert_eq!(commands[0][0], "git");
626    }
627
628    #[test]
629    fn parse_bash_lc_chained() {
630        let cmd = vec![
631            "bash".to_string(),
632            "-lc".to_string(),
633            "git status && cargo check".to_string(),
634        ];
635        let commands = parse_bash_lc_commands(&cmd);
636        assert!(commands.is_some());
637        let commands = commands.unwrap();
638        assert_eq!(commands.len(), 2);
639    }
640
641    #[test]
642    fn parse_non_bash_command_returns_none() {
643        let cmd = vec!["echo".to_string(), "hello".to_string()];
644        let commands = parse_bash_lc_commands(&cmd);
645        assert!(commands.is_none());
646    }
647
648    #[test]
649    fn parse_bash_without_lc_returns_none() {
650        let cmd = vec!["bash".to_string(), "script.sh".to_string()];
651        let commands = parse_bash_lc_commands(&cmd);
652        assert!(commands.is_none());
653    }
654
655    // Phase 4 tests: Tree-sitter based parsing
656
657    #[test]
658    fn parse_complex_pipeline() {
659        let script = "cat file.txt | grep -i pattern | sort";
660        let commands = parse_shell_commands(script).unwrap();
661        assert!(!commands.is_empty());
662    }
663
664    #[test]
665    fn parse_with_pipes_and_redirects() {
666        let script = "ls -la | grep file > output.txt";
667        let commands = parse_shell_commands(script).unwrap();
668        assert!(!commands.is_empty());
669    }
670
671    #[test]
672    fn parse_command_substitution_fallback() {
673        let script = "echo $(git status)";
674        let commands = parse_shell_commands(script).unwrap();
675        assert!(!commands.is_empty());
676    }
677
678    #[test]
679    fn parse_escaped_quotes() {
680        let script = r#"echo "hello \"world\"""#;
681        let commands = parse_shell_commands(script).unwrap();
682        assert!(!commands.is_empty());
683    }
684
685    #[test]
686    fn parse_tree_sitter_preserves_command_name_with_quoted_args() {
687        let script = r#"echo "fish and chips""#;
688        let commands = parse_shell_commands_tree_sitter(script).unwrap();
689        assert!(!commands.is_empty());
690        assert_eq!(commands[0][0], "echo");
691    }
692
693    #[test]
694    fn parse_tree_sitter_preserves_single_and_ansi_quoted_args() {
695        let script = r#"printf '\n' && git diff '--output=out.txt' && printf $'\n'"#;
696        let commands = parse_shell_commands_tree_sitter(script).unwrap();
697        assert!(
698            commands
699                .iter()
700                .any(|command| command.iter().any(|word| word.contains("--output=out.txt")))
701        );
702        assert!(commands.iter().any(|command| command.iter().any(|word| word.contains("\\n"))));
703    }
704
705    #[test]
706    fn dynamic_syntax_allows_literal_escapes_inside_double_quoted_arguments() {
707        assert!(!contains_dynamic_shell_syntax(r#"rg -n "\[profile|lto|codegen-units|strip" Cargo.toml"#));
708        assert!(!contains_dynamic_shell_syntax(r#"printf "\nTop-level:\n""#));
709        assert!(!contains_dynamic_shell_syntax(r#"printf "quoted: \"value\"""#));
710        assert!(contains_dynamic_shell_syntax(r#"echo "safe\"$(id)""#));
711    }
712
713    #[test]
714    fn dynamic_syntax_rejects_unquoted_escapes() {
715        assert!(contains_dynamic_shell_syntax(r"rg -n \[profile Cargo.toml"));
716    }
717
718    #[test]
719    fn output_redirection_guard_rejects_input_and_heredoc_shapes() {
720        assert!(has_only_output_redirections("cargo check > build.log 2>&1"));
721        assert!(has_only_output_redirections("cargo check | head -40 > build.log"));
722        assert!(has_only_output_redirections("cargo check &> build.log"));
723        assert!(has_only_output_redirections("cargo check &>> build.log"));
724        assert!(!has_only_output_redirections("cargo check < build-input.log"));
725        assert!(!has_only_output_redirections("cargo check <<'EOF'\ninput\nEOF"));
726        assert!(!has_only_output_redirections("cargo check > $(printf build.log)"));
727        assert!(!has_only_output_redirections("cargo check > build.log &"));
728        assert!(!has_only_output_redirections("cargo check 2>&-"));
729    }
730
731    #[test]
732    fn strict_tree_sitter_parser_rejects_incomplete_shell_syntax() {
733        assert!(parse_shell_commands_tree_sitter("cargo check &&").is_err());
734        assert!(parse_shell_commands_tree_sitter("echo '").is_err());
735    }
736
737    #[test]
738    fn parse_bash_lc_with_pipe() {
739        let cmd = vec!["bash".to_string(), "-lc".to_string(), "ls -la | head -5".to_string()];
740        let commands = parse_bash_lc_commands(&cmd);
741        assert!(commands.is_some());
742        let cmds = commands.unwrap();
743        assert!(!cmds.is_empty());
744    }
745
746    #[test]
747    fn parse_dangerous_shell_command() {
748        let script = "rm -rf /; echo done";
749        let commands = parse_shell_commands(script).unwrap();
750        assert_eq!(commands.len(), 2);
751        assert_eq!(commands[0][0], "rm");
752    }
753
754    #[test]
755    fn prewarm_bash_parser_initializes_successfully() {
756        prewarm_bash_parser().expect("bash parser should initialize");
757    }
758
759    #[test]
760    fn dynamic_find_syntax_is_detected_without_rejecting_quoted_globs() {
761        assert!(contains_dynamic_find_syntax("find src -maxdepth 0 -exe$''c touch /tmp/VT_BYPASS_POC {} +"));
762        assert!(!contains_dynamic_find_syntax("find src -type f -name '*.rs'"));
763    }
764
765    #[test]
766    fn cached_tree_hit_still_fails_closed_on_malformed_scripts() {
767        // The tree cache is keyed by exact source text. The second call takes a
768        // cache hit, so `has_error` must be preserved across the cache or a
769        // malformed script would pass on the second parse after failing the
770        // first — a fail-open regression.
771        let malformed = "cargo check && ";
772        assert!(parse_shell_commands_tree_sitter(malformed).is_err());
773        assert!(
774            parse_shell_commands_tree_sitter(malformed).is_err(),
775            "cached tree must keep rejecting malformed scripts"
776        );
777    }
778
779    #[test]
780    fn cached_tree_hit_preserves_extraction_across_repeated_and_mixed_calls() {
781        // Cache hits must return the same grammar output for the same source,
782        // across both strict and lenient entry points, so safety and approval
783        // classification cannot diverge between the first and later parses.
784        let script = "vtcode-cache-extraction-probe && grep -n pattern file | head -5";
785        let first = parse_shell_commands(script).expect("initial parse");
786        let second = parse_shell_commands(script).expect("cached parse");
787        assert_eq!(first, second);
788        assert_eq!(first.len(), 3);
789        assert!(first.iter().any(|command| command.first().is_some_and(|name| name == "grep")));
790
791        let strict = parse_shell_commands_tree_sitter(script).expect("strict parse of cached tree");
792        assert_eq!(strict, first);
793    }
794}
795
796// === Injection detection (moved from tools::validation::commands) ===
797
798use anyhow::bail;
799
800/// Delimiter token of a quoted heredoc starting just after `<<` / `<<-`.
801///
802/// Returns `(delimiter, token_byte_len)` where the token covers the optional
803/// `-`, surrounding whitespace, and the quoted delimiter word. Bare
804/// (unquoted) delimiters return `None`: those bodies still allow substitution.
805pub(crate) fn quoted_heredoc_delim(rest: &str) -> Option<(String, usize)> {
806    let mut idx = 0usize;
807    let bytes = rest.as_bytes();
808    if bytes.first() == Some(&b'-') {
809        idx += 1;
810    }
811    while idx < bytes.len() && (bytes[idx] == b' ' || bytes[idx] == b'\t') {
812        idx += 1;
813    }
814    let quote = match bytes.get(idx) {
815        Some(b'\'') | Some(b'"') => bytes[idx],
816        _ => return None,
817    };
818    idx += 1;
819    let delim_start = idx;
820    while idx < bytes.len() && bytes[idx] != quote {
821        idx += 1;
822    }
823    if idx >= bytes.len() || idx == delim_start {
824        return None;
825    }
826    let delim = rest[delim_start..idx].to_string();
827    idx += 1; // closing quote
828    Some((delim, idx))
829}
830
831/// Byte length of a quoted heredoc body starting just after the opener line's
832/// newline, through the closing delimiter line (inclusive). Returns `None`
833/// when the closing delimiter never appears so callers keep scanning the
834/// unterminated body and fail closed on anything executable inside it.
835pub(crate) fn heredoc_body_skip_len(rest: &str, delim: &str) -> Option<usize> {
836    let bytes = rest.as_bytes();
837    let mut idx = 0usize;
838    while idx <= bytes.len() {
839        let line_end = rest[idx..].find('\n').map(|offset| idx + offset).unwrap_or(bytes.len());
840        let line = rest[idx..line_end].trim_end_matches('\r');
841        let next = if line_end < bytes.len() {
842            line_end + 1
843        } else {
844            bytes.len()
845        };
846        if line == delim {
847            return Some(next);
848        }
849        if line_end >= bytes.len() {
850            return None;
851        }
852        idx = next;
853    }
854    None
855}
856
857/// Advance a `CharIndices` iterator by at most `max_bytes`, returning the byte
858/// index just past the last consumed char (or `fallback` when nothing remains).
859fn consume_bytes(
860    chars: &mut std::iter::Peekable<std::str::CharIndices<'_>>,
861    max_bytes: usize,
862    fallback: usize,
863) -> usize {
864    let mut consumed = 0usize;
865    let mut end = fallback;
866    while consumed < max_bytes
867        && let Some((idx, ch)) = chars.next()
868    {
869        consumed += ch.len_utf8();
870        end = idx + ch.len_utf8();
871    }
872    end
873}
874
875/// Quote state for shell segment splitting.
876#[derive(Clone, Copy, Eq, PartialEq)]
877enum QuoteState {
878    None,
879    Single,
880    Double,
881}
882
883/// Split a shell command into segments on unquoted `|` and `&` boundaries,
884/// while detecting injection patterns (`;`, backticks, `$()`, newlines).
885pub(crate) fn split_shell_segments(command: &str) -> Result<Vec<String>> {
886    let mut segments = Vec::new();
887    let mut state = QuoteState::None;
888    let mut escaped = false;
889    let mut segment_start = 0usize;
890    // Delimiter of a `<<'EOF'`-shaped heredoc whose opener line has not ended
891    // yet. The opener line stays live shell; the body is skipped at the
892    // unquoted newline.
893    let mut pending_heredoc: Option<String> = None;
894    let mut chars = command.char_indices().peekable();
895
896    while let Some((idx, ch)) = chars.next() {
897        match state {
898            QuoteState::Single => {
899                if ch == '\'' {
900                    state = QuoteState::None;
901                }
902            }
903            QuoteState::Double => {
904                if escaped {
905                    escaped = false;
906                    continue;
907                }
908
909                match ch {
910                    '\\' => escaped = true,
911                    '"' => state = QuoteState::None,
912                    '`' => bail!("Command injection pattern detected"),
913                    '$' if matches!(chars.peek(), Some((_, '('))) => {
914                        bail!("Command injection pattern detected");
915                    }
916                    _ => {}
917                }
918            }
919            QuoteState::None => {
920                if escaped {
921                    escaped = false;
922                    continue;
923                }
924
925                match ch {
926                    '\\' => escaped = true,
927                    '\'' => state = QuoteState::Single,
928                    '"' => state = QuoteState::Double,
929                    // Quoted heredoc bodies are literal data (newlines, backticks
930                    // included). Arm the skip here but keep scanning: everything
931                    // after the delimiter on the opener line is live shell, so
932                    // operators there must still split segments or bail. The
933                    // body is skipped at the unquoted opener newline below.
934                    '<' if matches!(chars.peek(), Some((_, '<'))) => {
935                        let _ = chars.next(); // second '<'
936                        let rest: String = chars.clone().map(|(_, c)| c).collect();
937                        if let Some((delim, token_len)) = quoted_heredoc_delim(&rest) {
938                            let _ = consume_bytes(&mut chars, token_len, idx + ch.len_utf8());
939                            pending_heredoc = Some(delim);
940                        }
941                    }
942                    '`' => bail!("Command injection pattern detected"),
943                    '$' if matches!(chars.peek(), Some((_, '('))) => {
944                        bail!("Command injection pattern detected");
945                    }
946                    ';' => bail!("Unquoted command chaining detected"),
947                    '\n' => {
948                        // The opener line ended in unquoted context: the quoted
949                        // heredoc body starts here and is literal data. Without
950                        // a closing delimiter the body keeps being scanned, so
951                        // executable content in it still fails closed.
952                        if let Some(delim) = pending_heredoc.take() {
953                            let rest: String = chars.clone().map(|(_, c)| c).collect();
954                            if let Some(skip) = heredoc_body_skip_len(&rest, &delim) {
955                                // The opener line is a live command; the body
956                                // belongs to no segment.
957                                push_segment(command, segment_start, idx, &mut segments);
958                                segment_start = consume_bytes(&mut chars, skip, command.len());
959                                continue;
960                            }
961                        }
962                        bail!(
963                            "multi-line shell commands are not allowed; write the content with `apply_patch` instead of a heredoc"
964                        );
965                    }
966                    '|' | '&' => {
967                        push_segment(command, segment_start, idx, &mut segments);
968                        segment_start = idx + ch.len_utf8();
969                        if let Some((next_idx, next_ch)) = chars.peek().copied()
970                            && next_ch == ch
971                        {
972                            let _next = chars.next();
973                            segment_start = next_idx + next_ch.len_utf8();
974                        }
975                    }
976                    _ => {}
977                }
978            }
979        }
980    }
981
982    push_segment(command, segment_start, command.len(), &mut segments);
983    Ok(segments)
984}
985
986fn push_segment(command: &str, start: usize, end: usize, segments: &mut Vec<String>) {
987    let segment = command[start..end].trim();
988    if !segment.is_empty() {
989        segments.push(segment.to_string());
990    }
991}
992
993/// Check for additional dangerous patterns not covered by the central dangerous-command detector.
994pub(crate) fn additional_dangerous_pattern(segment: &str) -> Option<&'static str> {
995    let segment_lower = segment.to_ascii_lowercase();
996    if segment_lower.starts_with(":(){:|:&};:") {
997        return Some(":(){:|:&};:");
998    }
999
1000    let tokens =
1001        shell_words::split(segment).unwrap_or_else(|_| segment.split_whitespace().map(ToString::to_string).collect());
1002    let first = tokens.first()?;
1003    let command_name = base_command_name(strip_wrapping_quotes(first)).to_ascii_lowercase();
1004
1005    match command_name.as_str() {
1006        "rmdir" => Some("rmdir"),
1007        "wget" => Some("wget"),
1008        "curl" => Some("curl"),
1009        "chmod" if tokens.iter().skip(1).any(|arg| strip_wrapping_quotes(arg).starts_with("777")) => Some("chmod 777"),
1010        "chown"
1011            if tokens.iter().skip(1).any(|arg| {
1012                let arg = strip_wrapping_quotes(arg).to_ascii_lowercase();
1013                arg == "root" || arg.starts_with("root:")
1014            }) =>
1015        {
1016            Some("chown root")
1017        }
1018        _ => None,
1019    }
1020}
1021
1022fn strip_wrapping_quotes(token: &str) -> &str {
1023    token
1024        .strip_prefix('\'')
1025        .and_then(|token| token.strip_suffix('\''))
1026        .or_else(|| token.strip_prefix('"').and_then(|token| token.strip_suffix('"')))
1027        .unwrap_or(token)
1028}
1029
1030fn base_command_name(command: &str) -> &str {
1031    std::path::Path::new(command)
1032        .file_name()
1033        .and_then(|name| name.to_str())
1034        .unwrap_or(command)
1035}