Skip to main content

vtcode_safety/command_safety/
shell_parser.rs

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