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