Skip to main content

codex_shell_command/
bash.rs

1use std::path::PathBuf;
2
3use tree_sitter::Node;
4use tree_sitter::Parser;
5use tree_sitter::Tree;
6use tree_sitter_bash::LANGUAGE as BASH;
7
8use crate::shell_detect::ShellType;
9use crate::shell_detect::detect_shell_type;
10
11/// Parse the provided bash source using tree-sitter-bash, returning a Tree on
12/// success or None if parsing failed.
13pub fn try_parse_shell(shell_lc_arg: &str) -> Option<Tree> {
14    let lang = BASH.into();
15    let mut parser = Parser::new();
16    #[expect(clippy::expect_used)]
17    parser.set_language(&lang).expect("load bash grammar");
18    let old_tree: Option<&Tree> = None;
19    parser.parse(shell_lc_arg, old_tree)
20}
21
22/// Parse a script which may contain multiple simple commands joined only by
23/// the safe logical/pipe/sequencing operators: `&&`, `||`, `;`, `|`.
24///
25/// Returns `Some(Vec<command_words>)` if every command is a plain word‑only
26/// command and the parse tree does not contain disallowed constructs
27/// (parentheses, redirections, substitutions, control flow, etc.). Otherwise
28/// returns `None`.
29pub fn try_parse_word_only_commands_sequence(tree: &Tree, src: &str) -> Option<Vec<Vec<String>>> {
30    if tree.root_node().has_error() {
31        return None;
32    }
33
34    // List of allowed (named) node kinds for a "word only commands sequence".
35    // If we encounter a named node that is not in this list we reject.
36    const ALLOWED_KINDS: &[&str] = &[
37        // top level containers
38        "program",
39        "list",
40        "pipeline",
41        // commands & words
42        "command",
43        "command_name",
44        "word",
45        "string",
46        "string_content",
47        "raw_string",
48        "number",
49        "concatenation",
50    ];
51    // Allow only safe punctuation / operator tokens; anything else causes reject.
52    const ALLOWED_PUNCT_TOKENS: &[&str] = &["&&", "||", ";", "|", "\"", "'"];
53
54    let root = tree.root_node();
55    let mut cursor = root.walk();
56    let mut stack = vec![root];
57    let mut command_nodes = Vec::new();
58    while let Some(node) = stack.pop() {
59        let kind = node.kind();
60        if node.is_named() {
61            if !ALLOWED_KINDS.contains(&kind) {
62                return None;
63            }
64            if kind == "command" {
65                command_nodes.push(node);
66            }
67        } else {
68            // Reject any punctuation / operator tokens that are not explicitly allowed.
69            if kind.chars().any(|c| "&;|".contains(c)) && !ALLOWED_PUNCT_TOKENS.contains(&kind) {
70                return None;
71            }
72            if !(ALLOWED_PUNCT_TOKENS.contains(&kind) || kind.trim().is_empty()) {
73                // If it's a quote token or operator it's allowed above; we also allow whitespace tokens.
74                // Any other punctuation like parentheses, braces, redirects, backticks, etc are rejected.
75                return None;
76            }
77        }
78        for child in node.children(&mut cursor) {
79            stack.push(child);
80        }
81    }
82
83    // Walk uses a stack (LIFO), so re-sort by position to restore source order.
84    command_nodes.sort_by_key(Node::start_byte);
85
86    let mut commands = Vec::new();
87    for node in command_nodes {
88        if let Some(words) = parse_plain_command_from_node(node, src) {
89            commands.push(words);
90        } else {
91            return None;
92        }
93    }
94    Some(commands)
95}
96
97/// Parses a shell script consisting only of plain commands joined by safe operators.
98pub fn parse_shell_script_into_commands(script: &str) -> Option<Vec<Vec<String>>> {
99    let tree = try_parse_shell(script)?;
100    try_parse_word_only_commands_sequence(&tree, script)
101}
102
103pub fn extract_bash_command(command: &[String]) -> Option<(&str, &str)> {
104    let [shell, flag, script] = command else {
105        return None;
106    };
107    if !matches!(flag.as_str(), "-lc" | "-c")
108        || !matches!(
109            detect_shell_type(PathBuf::from(shell)),
110            Some(ShellType::Zsh) | Some(ShellType::Bash) | Some(ShellType::Sh)
111        )
112    {
113        return None;
114    }
115    Some((shell, script))
116}
117
118/// Returns the sequence of plain commands within a `bash -lc "..."` or
119/// `zsh -lc "..."` invocation when the script only contains word-only commands
120/// joined by safe operators.
121pub fn parse_shell_lc_plain_commands(command: &[String]) -> Option<Vec<Vec<String>>> {
122    let (_, script) = extract_bash_command(command)?;
123    parse_shell_script_into_commands(script)
124}
125
126/// Extracts the literal portions of command invocations from a shell script.
127///
128/// Unlike [`parse_shell_lc_plain_commands`], this accepts complex shell syntax
129/// and returns the statically known words from every command node in a valid
130/// syntax tree. Dynamic words and redirections are omitted. This is suitable
131/// for identifying dangerous literal commands, but must not be used to prove
132/// that a command is safe.
133pub(crate) fn parse_shell_lc_literal_commands(command: &[String]) -> Option<Vec<Vec<String>>> {
134    let (_, script) = extract_bash_command(command)?;
135    let tree = try_parse_shell(script)?;
136    let root = tree.root_node();
137    if root.has_error() {
138        return None;
139    }
140
141    let mut commands = Vec::new();
142    let mut stack = vec![root];
143    while let Some(node) = stack.pop() {
144        if node.kind() == "command"
145            && let Some(command) = parse_literal_command_from_node(node, script)
146        {
147            commands.push(command);
148        }
149
150        let mut cursor = node.walk();
151        for child in node.named_children(&mut cursor) {
152            stack.push(child);
153        }
154    }
155
156    Some(commands)
157}
158
159/// Returns the parsed argv for a single shell command in a here-doc style
160/// script (`<<`), as long as the script contains exactly one command node.
161pub fn parse_shell_lc_single_command_prefix(command: &[String]) -> Option<Vec<String>> {
162    let (_, script) = extract_bash_command(command)?;
163    let tree = try_parse_shell(script)?;
164    let root = tree.root_node();
165    if root.has_error() {
166        return None;
167    }
168    if !has_named_descendant_kind(root, "heredoc_redirect") {
169        return None;
170    }
171    if has_named_descendant_kind(root, "file_redirect") {
172        return None;
173    }
174
175    let command_node = find_single_command_node(root)?;
176    parse_heredoc_command_words(command_node, script)
177}
178
179fn parse_plain_command_from_node(cmd: tree_sitter::Node, src: &str) -> Option<Vec<String>> {
180    if cmd.kind() != "command" {
181        return None;
182    }
183    let mut words = Vec::new();
184    let mut cursor = cmd.walk();
185    for child in cmd.named_children(&mut cursor) {
186        match child.kind() {
187            "command_name" => {
188                let word_node = child.named_child(0)?;
189                if word_node.kind() != "word" {
190                    return None;
191                }
192                words.push(word_node.utf8_text(src.as_bytes()).ok()?.to_owned());
193            }
194            "word" | "number" => {
195                words.push(child.utf8_text(src.as_bytes()).ok()?.to_owned());
196            }
197            "string" => {
198                let parsed = parse_double_quoted_string(child, src)?;
199                words.push(parsed);
200            }
201            "raw_string" => {
202                let parsed = parse_raw_string(child, src)?;
203                words.push(parsed);
204            }
205            "concatenation" => {
206                // Handle concatenated arguments like -g"*.py"
207                let mut concatenated = String::new();
208                let mut concat_cursor = child.walk();
209                for part in child.named_children(&mut concat_cursor) {
210                    match part.kind() {
211                        "word" | "number" => {
212                            concatenated
213                                .push_str(part.utf8_text(src.as_bytes()).ok()?.to_owned().as_str());
214                        }
215                        "string" => {
216                            let parsed = parse_double_quoted_string(part, src)?;
217                            concatenated.push_str(&parsed);
218                        }
219                        "raw_string" => {
220                            let parsed = parse_raw_string(part, src)?;
221                            concatenated.push_str(&parsed);
222                        }
223                        _ => return None,
224                    }
225                }
226                if concatenated.is_empty() {
227                    return None;
228                }
229                words.push(concatenated);
230            }
231            _ => return None,
232        }
233    }
234    Some(words)
235}
236
237fn parse_literal_command_from_node(cmd: Node<'_>, src: &str) -> Option<Vec<String>> {
238    if cmd.kind() != "command" {
239        return None;
240    }
241
242    let mut words = Vec::new();
243    let mut found_command_name = false;
244    let mut cursor = cmd.walk();
245    for child in cmd.named_children(&mut cursor) {
246        if child.kind() == "command_name" {
247            let command_name = parse_literal_shell_word(child.named_child(0)?, src)?;
248            words.push(command_name);
249            found_command_name = true;
250        } else if found_command_name && let Some(word) = parse_literal_shell_word(child, src) {
251            words.push(word);
252        }
253    }
254
255    found_command_name.then_some(words)
256}
257
258fn parse_literal_shell_word(node: Node<'_>, src: &str) -> Option<String> {
259    match node.kind() {
260        "word" | "number" if is_literal_word_or_number(node) => {
261            Some(node.utf8_text(src.as_bytes()).ok()?.to_owned())
262        }
263        "string" => parse_double_quoted_string(node, src),
264        "raw_string" => parse_raw_string(node, src),
265        "concatenation" => {
266            let mut concatenated = String::new();
267            let mut cursor = node.walk();
268            for part in node.named_children(&mut cursor) {
269                concatenated.push_str(&parse_literal_shell_word(part, src)?);
270            }
271            (!concatenated.is_empty()).then_some(concatenated)
272        }
273        _ => None,
274    }
275}
276
277fn parse_heredoc_command_words(cmd: Node<'_>, src: &str) -> Option<Vec<String>> {
278    if cmd.kind() != "command" {
279        return None;
280    }
281
282    let mut words = Vec::new();
283    let mut cursor = cmd.walk();
284    for child in cmd.named_children(&mut cursor) {
285        match child.kind() {
286            "command_name" => {
287                let word_node = child.named_child(0)?;
288                if !matches!(word_node.kind(), "word" | "number")
289                    || !is_literal_word_or_number(word_node)
290                {
291                    return None;
292                }
293                words.push(word_node.utf8_text(src.as_bytes()).ok()?.to_owned());
294            }
295            "word" | "number" => {
296                if !is_literal_word_or_number(child) {
297                    return None;
298                }
299                words.push(child.utf8_text(src.as_bytes()).ok()?.to_owned());
300            }
301            // Allow heredoc constructs that attach stdin to a single command
302            // without changing argv matching semantics for the executable
303            // prefix. Other file redirects may write outside the sandbox and
304            // must not be collapsed to the executable prefix for execpolicy.
305            "comment" => {}
306            kind if is_allowed_heredoc_attachment_kind(kind) => {}
307            _ => return None,
308        }
309    }
310
311    if words.is_empty() { None } else { Some(words) }
312}
313
314fn is_literal_word_or_number(node: Node<'_>) -> bool {
315    if !matches!(node.kind(), "word" | "number") {
316        return false;
317    }
318    let mut cursor = node.walk();
319    node.named_children(&mut cursor).next().is_none()
320}
321
322fn is_allowed_heredoc_attachment_kind(kind: &str) -> bool {
323    matches!(
324        kind,
325        "heredoc_body"
326            | "simple_heredoc_body"
327            | "heredoc_redirect"
328            | "herestring_redirect"
329            | "redirected_statement"
330    )
331}
332
333fn find_single_command_node(root: Node<'_>) -> Option<Node<'_>> {
334    let mut stack = vec![root];
335    let mut single_command = None;
336    while let Some(node) = stack.pop() {
337        if node.kind() == "command" {
338            if single_command.is_some() {
339                return None;
340            }
341            single_command = Some(node);
342        }
343
344        let mut cursor = node.walk();
345        for child in node.named_children(&mut cursor) {
346            stack.push(child);
347        }
348    }
349    single_command
350}
351
352fn has_named_descendant_kind(node: Node<'_>, kind: &str) -> bool {
353    let mut stack = vec![node];
354    while let Some(current) = stack.pop() {
355        if current.kind() == kind {
356            return true;
357        }
358        let mut cursor = current.walk();
359        for child in current.named_children(&mut cursor) {
360            stack.push(child);
361        }
362    }
363    false
364}
365
366fn parse_double_quoted_string(node: Node, src: &str) -> Option<String> {
367    if node.kind() != "string" {
368        return None;
369    }
370
371    let mut cursor = node.walk();
372    for part in node.named_children(&mut cursor) {
373        if part.kind() != "string_content" {
374            return None;
375        }
376    }
377    let raw = node.utf8_text(src.as_bytes()).ok()?;
378    let stripped = raw
379        .strip_prefix('"')
380        .and_then(|text| text.strip_suffix('"'))?;
381    Some(stripped.to_string())
382}
383
384fn parse_raw_string(node: Node, src: &str) -> Option<String> {
385    if node.kind() != "raw_string" {
386        return None;
387    }
388
389    let raw_string = node.utf8_text(src.as_bytes()).ok()?;
390    let stripped = raw_string
391        .strip_prefix('\'')
392        .and_then(|s| s.strip_suffix('\''));
393    stripped.map(str::to_owned)
394}
395
396#[cfg(test)]
397mod tests {
398    use super::*;
399    use pretty_assertions::assert_eq;
400
401    fn parse_seq(src: &str) -> Option<Vec<Vec<String>>> {
402        parse_shell_script_into_commands(src)
403    }
404
405    #[test]
406    fn accepts_single_simple_command() {
407        let cmds = parse_seq("ls -1").unwrap();
408        assert_eq!(cmds, vec![vec!["ls".to_string(), "-1".to_string()]]);
409    }
410
411    #[test]
412    fn accepts_multiple_commands_with_allowed_operators() {
413        let src = "ls && pwd; echo 'hi there' | wc -l";
414        let cmds = parse_seq(src).unwrap();
415        let expected: Vec<Vec<String>> = vec![
416            vec!["ls".to_string()],
417            vec!["pwd".to_string()],
418            vec!["echo".to_string(), "hi there".to_string()],
419            vec!["wc".to_string(), "-l".to_string()],
420        ];
421        assert_eq!(cmds, expected);
422    }
423
424    #[test]
425    fn extracts_double_and_single_quoted_strings() {
426        let cmds = parse_seq("echo \"hello world\"").unwrap();
427        assert_eq!(
428            cmds,
429            vec![vec!["echo".to_string(), "hello world".to_string()]]
430        );
431
432        let cmds2 = parse_seq("echo 'hi there'").unwrap();
433        assert_eq!(
434            cmds2,
435            vec![vec!["echo".to_string(), "hi there".to_string()]]
436        );
437    }
438
439    #[test]
440    fn accepts_double_quoted_strings_with_newlines() {
441        let cmds = parse_seq("git commit -m \"line1\nline2\"").unwrap();
442        assert_eq!(
443            cmds,
444            vec![vec![
445                "git".to_string(),
446                "commit".to_string(),
447                "-m".to_string(),
448                "line1\nline2".to_string(),
449            ]]
450        );
451    }
452
453    #[test]
454    fn accepts_mixed_quote_concatenation() {
455        assert_eq!(
456            parse_seq(r#"echo "/usr"'/'"local"/bin"#).unwrap(),
457            vec![vec!["echo".to_string(), "/usr/local/bin".to_string()]]
458        );
459        assert_eq!(
460            parse_seq(r#"echo '/usr'"/"'local'/bin"#).unwrap(),
461            vec![vec!["echo".to_string(), "/usr/local/bin".to_string()]]
462        );
463    }
464
465    #[test]
466    fn rejects_double_quoted_strings_with_expansions() {
467        assert!(parse_seq(r#"echo "hi ${USER}""#).is_none());
468        assert!(parse_seq(r#"echo "$HOME""#).is_none());
469    }
470
471    #[test]
472    fn accepts_numbers_as_words() {
473        let cmds = parse_seq("echo 123 456").unwrap();
474        assert_eq!(
475            cmds,
476            vec![vec![
477                "echo".to_string(),
478                "123".to_string(),
479                "456".to_string()
480            ]]
481        );
482    }
483
484    #[test]
485    fn rejects_parentheses_and_subshells() {
486        assert!(parse_seq("(ls)").is_none());
487        assert!(parse_seq("ls || (pwd && echo hi)").is_none());
488    }
489
490    #[test]
491    fn rejects_redirections_and_unsupported_operators() {
492        assert!(parse_seq("ls > out.txt").is_none());
493        assert!(parse_seq("echo hi & echo bye").is_none());
494    }
495
496    #[test]
497    fn rejects_command_and_process_substitutions_and_expansions() {
498        assert!(parse_seq("echo $(pwd)").is_none());
499        assert!(parse_seq("echo `pwd`").is_none());
500        assert!(parse_seq("echo $HOME").is_none());
501        assert!(parse_seq("echo \"hi $USER\"").is_none());
502    }
503
504    #[test]
505    fn rejects_variable_assignment_prefix() {
506        assert!(parse_seq("FOO=bar ls").is_none());
507    }
508
509    #[test]
510    fn rejects_trailing_operator_parse_error() {
511        assert!(parse_seq("ls &&").is_none());
512    }
513
514    #[test]
515    fn rejects_empty_command_position_with_leading_operator() {
516        assert!(parse_seq("&& ls").is_none());
517    }
518
519    #[test]
520    fn rejects_empty_command_position_with_double_separator() {
521        assert!(parse_seq("ls ;; pwd").is_none());
522    }
523
524    #[test]
525    fn rejects_empty_command_position_with_empty_pipeline_segment() {
526        assert!(parse_seq("ls | | wc").is_none());
527    }
528
529    #[test]
530    fn parse_zsh_lc_plain_commands() {
531        let command = vec!["zsh".to_string(), "-lc".to_string(), "ls".to_string()];
532        let parsed = parse_shell_lc_plain_commands(&command).unwrap();
533        assert_eq!(parsed, vec![vec!["ls".to_string()]]);
534    }
535
536    #[test]
537    fn accepts_concatenated_flag_and_value() {
538        // Test case: -g"*.py" (flag directly concatenated with quoted value)
539        let cmds = parse_seq("rg -n \"foo\" -g\"*.py\"").unwrap();
540        assert_eq!(
541            cmds,
542            vec![vec![
543                "rg".to_string(),
544                "-n".to_string(),
545                "foo".to_string(),
546                "-g*.py".to_string(),
547            ]]
548        );
549    }
550
551    #[test]
552    fn accepts_concatenated_flag_with_single_quotes() {
553        let cmds = parse_seq("grep -n 'pattern' -g'*.txt'").unwrap();
554        assert_eq!(
555            cmds,
556            vec![vec![
557                "grep".to_string(),
558                "-n".to_string(),
559                "pattern".to_string(),
560                "-g*.txt".to_string(),
561            ]]
562        );
563    }
564
565    #[test]
566    fn rejects_concatenation_with_variable_substitution() {
567        // Environment variables in concatenated strings should be rejected
568        assert!(parse_seq("rg -g\"$VAR\" pattern").is_none());
569        assert!(parse_seq("rg -g\"${VAR}\" pattern").is_none());
570    }
571
572    #[test]
573    fn rejects_concatenation_with_command_substitution() {
574        // Command substitution in concatenated strings should be rejected
575        assert!(parse_seq("rg -g\"$(pwd)\" pattern").is_none());
576        assert!(parse_seq("rg -g\"$(echo '*.py')\" pattern").is_none());
577    }
578
579    #[test]
580    fn parse_shell_lc_single_command_prefix_supports_heredoc() {
581        let command = vec![
582            "zsh".to_string(),
583            "-lc".to_string(),
584            "python3 <<'PY'\nprint('hello')\nPY".to_string(),
585        ];
586        let parsed = parse_shell_lc_single_command_prefix(&command);
587        assert_eq!(parsed, Some(vec!["python3".to_string()]));
588
589        let command_unquoted = vec![
590            "zsh".to_string(),
591            "-lc".to_string(),
592            "python3 << PY\nprint('hello')\nPY".to_string(),
593        ];
594        let parsed_unquoted = parse_shell_lc_single_command_prefix(&command_unquoted);
595        assert_eq!(parsed_unquoted, Some(vec!["python3".to_string()]));
596    }
597
598    #[test]
599    fn parse_shell_lc_single_command_prefix_rejects_multi_command_scripts() {
600        let command = vec![
601            "bash".to_string(),
602            "-lc".to_string(),
603            "python3 <<'PY'\nprint('hello')\nPY\necho done".to_string(),
604        ];
605        assert_eq!(parse_shell_lc_single_command_prefix(&command), None);
606    }
607
608    #[test]
609    fn parse_shell_lc_single_command_prefix_rejects_non_heredoc_redirects() {
610        let command = vec![
611            "bash".to_string(),
612            "-lc".to_string(),
613            "echo hello > /tmp/out.txt".to_string(),
614        ];
615        assert_eq!(parse_shell_lc_single_command_prefix(&command), None);
616    }
617
618    #[test]
619    fn parse_shell_lc_single_command_prefix_rejects_heredoc_with_extra_file_redirect() {
620        let command = vec![
621            "bash".to_string(),
622            "-lc".to_string(),
623            "python3 <<'PY' > /tmp/out.txt\nprint('hello')\nPY".to_string(),
624        ];
625        assert_eq!(parse_shell_lc_single_command_prefix(&command), None);
626    }
627
628    #[test]
629    fn parse_shell_lc_single_command_prefix_rejects_heredoc_with_variable_assignment() {
630        let command = vec![
631            "bash".to_string(),
632            "-lc".to_string(),
633            "PATH=/tmp/evil:$PATH cat <<'EOF'\nhello\nEOF".to_string(),
634        ];
635        assert_eq!(parse_shell_lc_single_command_prefix(&command), None);
636    }
637
638    #[test]
639    fn parse_shell_lc_single_command_prefix_rejects_herestring_with_chaining() {
640        let command = vec![
641            "bash".to_string(),
642            "-lc".to_string(),
643            r#"echo hello > /tmp/out.txt && cat /tmp/out.txt"#.to_string(),
644        ];
645        assert_eq!(parse_shell_lc_single_command_prefix(&command), None);
646    }
647
648    #[test]
649    fn parse_shell_lc_single_command_prefix_rejects_herestring_with_substitution() {
650        let command = vec![
651            "bash".to_string(),
652            "-lc".to_string(),
653            r#"python3 <<< "$(rm -rf /)""#.to_string(),
654        ];
655        assert_eq!(parse_shell_lc_single_command_prefix(&command), None);
656    }
657
658    #[test]
659    fn parse_shell_lc_single_command_prefix_rejects_arithmetic_shift_non_heredoc_script() {
660        let command = vec![
661            "bash".to_string(),
662            "-lc".to_string(),
663            "echo $((1<<2))".to_string(),
664        ];
665        assert_eq!(parse_shell_lc_single_command_prefix(&command), None);
666    }
667
668    #[test]
669    fn parse_shell_lc_single_command_prefix_rejects_heredoc_command_with_word_expansion() {
670        let command = vec![
671            "bash".to_string(),
672            "-lc".to_string(),
673            "python3 $((1<<2)) <<'PY'\nprint('hello')\nPY".to_string(),
674        ];
675        assert_eq!(parse_shell_lc_single_command_prefix(&command), None);
676    }
677}