Skip to main content

vtcode_safety/command_safety/shell_parser/
mod.rs

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