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