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        if matches!(
381            child.kind(),
382            "word" | "string" | "raw_string" | "ansi_c_string" | "simple_expansion" | "variable_expansion"
383        ) {
384            let text = child.utf8_text(source.as_bytes());
385            if let Ok(arg) = text {
386                let trimmed = arg.trim();
387                if !trimmed.is_empty() {
388                    command.push(trimmed.to_string());
389                }
390            }
391        }
392    }
393
394    if command.is_empty() { None } else { Some(command) }
395}
396
397/// Fallback: Parses shell script with simple tokenization
398fn parse_with_basic_tokenization(script: &str) -> Result<Vec<Vec<String>>, String> {
399    let mut commands = Vec::new();
400    let mut current_command = String::new();
401    let mut in_quotes = false;
402    let mut quote_char = ' ';
403    let mut escaped = false;
404
405    for ch in script.chars() {
406        if escaped {
407            current_command.push(ch);
408            escaped = false;
409            continue;
410        }
411
412        match ch {
413            '\\' => {
414                escaped = true;
415            }
416            '\'' | '"' if !in_quotes => {
417                in_quotes = true;
418                quote_char = ch;
419            }
420            c if c == quote_char && in_quotes => {
421                in_quotes = false;
422            }
423            '&' | '|' | ';' if !in_quotes => {
424                if !current_command.trim().is_empty()
425                    && let Ok(cmd) = tokenize_command(&current_command)
426                {
427                    commands.push(cmd);
428                }
429                current_command.clear();
430            }
431            _ => current_command.push(ch),
432        }
433    }
434
435    if !current_command.trim().is_empty()
436        && let Ok(cmd) = tokenize_command(&current_command)
437    {
438        commands.push(cmd);
439    }
440
441    Ok(commands)
442}
443
444/// Splits a command string into arguments
445/// Respects quoted strings and escapes
446fn tokenize_command(cmd: &str) -> Result<Vec<String>, String> {
447    shell_words::split(cmd).map_err(|err| format!("failed to tokenize command: {err}"))
448}
449
450/// Parses `bash -lc "script"` style invocations
451///
452/// # Example
453/// ```text
454/// Input:  vec!["bash", "-lc", "git status && rm /"]
455/// Output: Some([["git", "status"], ["rm", "/"]])
456/// ```
457pub fn parse_bash_lc_commands(command: &[String]) -> Option<Vec<Vec<String>>> {
458    if command.is_empty() {
459        return None;
460    }
461
462    let cmd_name = command[0].as_str();
463    let base_cmd = std::path::Path::new(cmd_name)
464        .file_name()
465        .and_then(|osstr| osstr.to_str())
466        .unwrap_or("");
467
468    if base_cmd != "bash" && base_cmd != "zsh" && base_cmd != "sh" {
469        return None;
470    }
471
472    // Look for -lc or -c pattern
473    for window in command.windows(2) {
474        if matches!(window[0].as_str(), "-lc" | "-c" | "-il" | "-ic") {
475            let script = &window[1];
476            return parse_shell_commands(script).ok();
477        }
478    }
479
480    None
481}
482
483#[cfg(test)]
484mod tests {
485    use super::*;
486
487    #[test]
488    fn tokenize_simple_command() {
489        let cmd = "git status";
490        let tokens = tokenize_command(cmd).unwrap();
491        assert_eq!(tokens, vec!["git", "status"]);
492    }
493
494    #[test]
495    fn tokenize_quoted_arguments() {
496        let cmd = r#"echo "hello world""#;
497        let tokens = tokenize_command(cmd).unwrap();
498        assert_eq!(tokens, vec!["echo", "hello world"]);
499    }
500
501    #[test]
502    fn parse_single_command() {
503        let script = "git status";
504        let commands = parse_shell_commands(script).unwrap();
505        assert_eq!(commands.len(), 1);
506        assert_eq!(commands[0][0], "git");
507    }
508
509    #[test]
510    fn parse_chained_commands_with_and() {
511        let script = "git status && cargo check";
512        let commands = parse_shell_commands(script).unwrap();
513        assert_eq!(commands.len(), 2);
514        assert_eq!(commands[0][0], "git");
515        assert_eq!(commands[1][0], "cargo");
516    }
517
518    #[test]
519    fn parse_loop_body_commands() {
520        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";
521        let commands = parse_shell_commands(script).unwrap();
522
523        assert_eq!(commands[0], vec!["cd", "crates/codegen/vtcode-core/src/tools/registry"]);
524        assert!(
525            commands
526                .iter()
527                .any(|command| command.first().is_some_and(|name| name == "echo"))
528        );
529        assert!(
530            commands
531                .iter()
532                .any(|command| command.first().is_some_and(|name| name == "grep"))
533        );
534        assert!(
535            commands
536                .iter()
537                .any(|command| command.first().is_some_and(|name| name == "head"))
538        );
539    }
540
541    #[test]
542    fn parse_chained_commands_with_semicolon() {
543        let script = "git status; cargo check";
544        let commands = parse_shell_commands(script).unwrap();
545        assert_eq!(commands.len(), 2);
546    }
547
548    #[test]
549    fn parse_bash_lc_git_status() {
550        let cmd = vec!["bash".to_string(), "-lc".to_string(), "git status".to_string()];
551        let commands = parse_bash_lc_commands(&cmd);
552        assert!(commands.is_some());
553        let commands = commands.unwrap();
554        assert_eq!(commands.len(), 1);
555        assert_eq!(commands[0][0], "git");
556    }
557
558    #[test]
559    fn parse_bash_lc_chained() {
560        let cmd = vec![
561            "bash".to_string(),
562            "-lc".to_string(),
563            "git status && cargo check".to_string(),
564        ];
565        let commands = parse_bash_lc_commands(&cmd);
566        assert!(commands.is_some());
567        let commands = commands.unwrap();
568        assert_eq!(commands.len(), 2);
569    }
570
571    #[test]
572    fn parse_non_bash_command_returns_none() {
573        let cmd = vec!["echo".to_string(), "hello".to_string()];
574        let commands = parse_bash_lc_commands(&cmd);
575        assert!(commands.is_none());
576    }
577
578    #[test]
579    fn parse_bash_without_lc_returns_none() {
580        let cmd = vec!["bash".to_string(), "script.sh".to_string()];
581        let commands = parse_bash_lc_commands(&cmd);
582        assert!(commands.is_none());
583    }
584
585    // Phase 4 tests: Tree-sitter based parsing
586
587    #[test]
588    fn parse_complex_pipeline() {
589        let script = "cat file.txt | grep -i pattern | sort";
590        let commands = parse_shell_commands(script).unwrap();
591        assert!(!commands.is_empty());
592    }
593
594    #[test]
595    fn parse_with_pipes_and_redirects() {
596        let script = "ls -la | grep file > output.txt";
597        let commands = parse_shell_commands(script).unwrap();
598        assert!(!commands.is_empty());
599    }
600
601    #[test]
602    fn parse_command_substitution_fallback() {
603        let script = "echo $(git status)";
604        let commands = parse_shell_commands(script).unwrap();
605        assert!(!commands.is_empty());
606    }
607
608    #[test]
609    fn parse_escaped_quotes() {
610        let script = r#"echo "hello \"world\"""#;
611        let commands = parse_shell_commands(script).unwrap();
612        assert!(!commands.is_empty());
613    }
614
615    #[test]
616    fn parse_tree_sitter_preserves_command_name_with_quoted_args() {
617        let script = r#"echo "fish and chips""#;
618        let commands = parse_shell_commands_tree_sitter(script).unwrap();
619        assert!(!commands.is_empty());
620        assert_eq!(commands[0][0], "echo");
621    }
622
623    #[test]
624    fn parse_tree_sitter_preserves_single_and_ansi_quoted_args() {
625        let script = r#"printf '\n' && git diff '--output=out.txt' && printf $'\n'"#;
626        let commands = parse_shell_commands_tree_sitter(script).unwrap();
627        assert!(
628            commands
629                .iter()
630                .any(|command| command.iter().any(|word| word.contains("--output=out.txt")))
631        );
632        assert!(commands.iter().any(|command| command.iter().any(|word| word.contains("\\n"))));
633    }
634
635    #[test]
636    fn dynamic_syntax_allows_literal_escapes_inside_double_quoted_arguments() {
637        assert!(!contains_dynamic_shell_syntax(r#"rg -n "\[profile|lto|codegen-units|strip" Cargo.toml"#));
638        assert!(!contains_dynamic_shell_syntax(r#"printf "\nTop-level:\n""#));
639        assert!(!contains_dynamic_shell_syntax(r#"printf "quoted: \"value\"""#));
640        assert!(contains_dynamic_shell_syntax(r#"echo "safe\"$(id)""#));
641    }
642
643    #[test]
644    fn dynamic_syntax_rejects_unquoted_escapes() {
645        assert!(contains_dynamic_shell_syntax(r"rg -n \[profile Cargo.toml"));
646    }
647
648    #[test]
649    fn output_redirection_guard_rejects_input_and_heredoc_shapes() {
650        assert!(has_only_output_redirections("cargo check > build.log 2>&1"));
651        assert!(has_only_output_redirections("cargo check | head -40 > build.log"));
652        assert!(has_only_output_redirections("cargo check &> build.log"));
653        assert!(has_only_output_redirections("cargo check &>> build.log"));
654        assert!(!has_only_output_redirections("cargo check < build-input.log"));
655        assert!(!has_only_output_redirections("cargo check <<'EOF'\ninput\nEOF"));
656        assert!(!has_only_output_redirections("cargo check > $(printf build.log)"));
657        assert!(!has_only_output_redirections("cargo check > build.log &"));
658        assert!(!has_only_output_redirections("cargo check 2>&-"));
659    }
660
661    #[test]
662    fn strict_tree_sitter_parser_rejects_incomplete_shell_syntax() {
663        assert!(parse_shell_commands_tree_sitter("cargo check &&").is_err());
664        assert!(parse_shell_commands_tree_sitter("echo '").is_err());
665    }
666
667    #[test]
668    fn parse_bash_lc_with_pipe() {
669        let cmd = vec!["bash".to_string(), "-lc".to_string(), "ls -la | head -5".to_string()];
670        let commands = parse_bash_lc_commands(&cmd);
671        assert!(commands.is_some());
672        let cmds = commands.unwrap();
673        assert!(!cmds.is_empty());
674    }
675
676    #[test]
677    fn parse_dangerous_shell_command() {
678        let script = "rm -rf /; echo done";
679        let commands = parse_shell_commands(script).unwrap();
680        assert_eq!(commands.len(), 2);
681        assert_eq!(commands[0][0], "rm");
682    }
683
684    #[test]
685    fn prewarm_bash_parser_initializes_successfully() {
686        prewarm_bash_parser().expect("bash parser should initialize");
687    }
688
689    #[test]
690    fn dynamic_find_syntax_is_detected_without_rejecting_quoted_globs() {
691        assert!(contains_dynamic_find_syntax("find src -maxdepth 0 -exe$''c touch /tmp/VT_BYPASS_POC {} +"));
692        assert!(!contains_dynamic_find_syntax("find src -type f -name '*.rs'"));
693    }
694}
695
696// === Injection detection (moved from tools::validation::commands) ===
697
698use anyhow::bail;
699
700/// Byte length of a quoted heredoc body starting just after `<<` / `<<-`.
701///
702/// Returns `Some(n)` where `n` covers the delimiter token, the rest of the
703/// opener line, and every body line through the closing delimiter line. Bare
704/// (unquoted) delimiters return `None`: those bodies still allow substitution.
705pub(crate) fn quoted_heredoc_skip_len(rest: &str) -> Option<usize> {
706    let mut idx = 0usize;
707    let bytes = rest.as_bytes();
708    if bytes.first() == Some(&b'-') {
709        idx += 1;
710    }
711    while idx < bytes.len() && (bytes[idx] == b' ' || bytes[idx] == b'\t') {
712        idx += 1;
713    }
714    let quote = match bytes.get(idx) {
715        Some(b'\'') | Some(b'"') => bytes[idx],
716        _ => return None,
717    };
718    idx += 1;
719    let delim_start = idx;
720    while idx < bytes.len() && bytes[idx] != quote {
721        idx += 1;
722    }
723    if idx >= bytes.len() || idx == delim_start {
724        return None;
725    }
726    let delim = &rest[delim_start..idx];
727    idx += 1; // closing quote
728    // Rest of the opener line.
729    while idx < bytes.len() && rest.as_bytes()[idx] != b'\n' {
730        idx += 1;
731    }
732    if idx < bytes.len() {
733        idx += 1; // consume opener newline
734    }
735    // Body lines through the delimiter line (inclusive).
736    while idx <= bytes.len() {
737        let line_end = rest[idx..].find('\n').map(|offset| idx + offset).unwrap_or(bytes.len());
738        let line = rest[idx..line_end].trim_end_matches('\r');
739        let next = if line_end < bytes.len() {
740            line_end + 1
741        } else {
742            bytes.len()
743        };
744        if line == delim {
745            return Some(next);
746        }
747        if line_end >= bytes.len() {
748            return None;
749        }
750        idx = next;
751    }
752    None
753}
754
755/// Skip a quoted heredoc body in a `CharIndices` iterator that peeks at the
756/// second `<` of `<<` (the first was already consumed). Returns true when a
757/// body was skipped. Leaves the iterator unchanged when the form is not a
758/// quoted heredoc.
759pub(crate) fn skip_quoted_heredoc(chars: &mut std::iter::Peekable<std::str::CharIndices<'_>>) -> bool {
760    // Probe without mutating so a failed guard does not eat the second `<`.
761    let mut probe = chars.clone();
762    let _ = probe.next(); // second '<'
763    let rest: String = probe.map(|(_, c)| c).collect();
764    let Some(skip) = quoted_heredoc_skip_len(&rest) else {
765        return false;
766    };
767    let _ = chars.next(); // second '<'
768    let mut consumed = 0usize;
769    while consumed < skip
770        && let Some((_, ch)) = chars.next()
771    {
772        consumed += ch.len_utf8();
773    }
774    true
775}
776
777/// Quote state for shell segment splitting.
778#[derive(Clone, Copy, Eq, PartialEq)]
779enum QuoteState {
780    None,
781    Single,
782    Double,
783}
784
785/// Split a shell command into segments on unquoted `|` and `&` boundaries,
786/// while detecting injection patterns (`;`, backticks, `$()`, newlines).
787pub(crate) fn split_shell_segments(command: &str) -> Result<Vec<String>> {
788    let mut segments = Vec::new();
789    let mut state = QuoteState::None;
790    let mut escaped = false;
791    let mut segment_start = 0usize;
792    let mut chars = command.char_indices().peekable();
793
794    while let Some((idx, ch)) = chars.next() {
795        match state {
796            QuoteState::Single => {
797                if ch == '\'' {
798                    state = QuoteState::None;
799                }
800            }
801            QuoteState::Double => {
802                if escaped {
803                    escaped = false;
804                    continue;
805                }
806
807                match ch {
808                    '\\' => escaped = true,
809                    '"' => state = QuoteState::None,
810                    '`' => bail!("Command injection pattern detected"),
811                    '$' if matches!(chars.peek(), Some((_, '('))) => {
812                        bail!("Command injection pattern detected");
813                    }
814                    _ => {}
815                }
816            }
817            QuoteState::None => {
818                if escaped {
819                    escaped = false;
820                    continue;
821                }
822
823                match ch {
824                    '\\' => escaped = true,
825                    '\'' => state = QuoteState::Single,
826                    '"' => state = QuoteState::Double,
827                    // Quoted heredoc bodies are literal data (newlines, backticks
828                    // included). Skip them so `cat <<'EOF'` payloads are not
829                    // treated as multi-line shell or injection.
830                    '<' if matches!(chars.peek(), Some((_, '<'))) && skip_quoted_heredoc(&mut chars) => {}
831                    '`' => bail!("Command injection pattern detected"),
832                    '$' if matches!(chars.peek(), Some((_, '('))) => {
833                        bail!("Command injection pattern detected");
834                    }
835                    ';' => bail!("Unquoted command chaining detected"),
836                    '\n' => bail!(
837                        "multi-line shell commands are not allowed; use file tools (write_file/apply_patch) for multi-line content instead of heredocs"
838                    ),
839                    '|' | '&' => {
840                        push_segment(command, segment_start, idx, &mut segments);
841                        segment_start = idx + ch.len_utf8();
842                        if let Some((next_idx, next_ch)) = chars.peek().copied()
843                            && next_ch == ch
844                        {
845                            let _next = chars.next();
846                            segment_start = next_idx + next_ch.len_utf8();
847                        }
848                    }
849                    _ => {}
850                }
851            }
852        }
853    }
854
855    push_segment(command, segment_start, command.len(), &mut segments);
856    Ok(segments)
857}
858
859fn push_segment(command: &str, start: usize, end: usize, segments: &mut Vec<String>) {
860    let segment = command[start..end].trim();
861    if !segment.is_empty() {
862        segments.push(segment.to_string());
863    }
864}
865
866/// Check for additional dangerous patterns not covered by the central dangerous-command detector.
867pub(crate) fn additional_dangerous_pattern(segment: &str) -> Option<&'static str> {
868    let segment_lower = segment.to_ascii_lowercase();
869    if segment_lower.starts_with(":(){:|:&};:") {
870        return Some(":(){:|:&};:");
871    }
872
873    let tokens =
874        shell_words::split(segment).unwrap_or_else(|_| segment.split_whitespace().map(ToString::to_string).collect());
875    let first = tokens.first()?;
876    let command_name = base_command_name(strip_wrapping_quotes(first)).to_ascii_lowercase();
877
878    match command_name.as_str() {
879        "rmdir" => Some("rmdir"),
880        "wget" => Some("wget"),
881        "curl" => Some("curl"),
882        "chmod" if tokens.iter().skip(1).any(|arg| strip_wrapping_quotes(arg).starts_with("777")) => Some("chmod 777"),
883        "chown"
884            if tokens.iter().skip(1).any(|arg| {
885                let arg = strip_wrapping_quotes(arg).to_ascii_lowercase();
886                arg == "root" || arg.starts_with("root:")
887            }) =>
888        {
889            Some("chown root")
890        }
891        _ => None,
892    }
893}
894
895fn strip_wrapping_quotes(token: &str) -> &str {
896    token
897        .strip_prefix('\'')
898        .and_then(|token| token.strip_suffix('\''))
899        .or_else(|| token.strip_prefix('"').and_then(|token| token.strip_suffix('"')))
900        .unwrap_or(token)
901}
902
903fn base_command_name(command: &str) -> &str {
904    std::path::Path::new(command)
905        .file_name()
906        .and_then(|name| name.to_str())
907        .unwrap_or(command)
908}