Skip to main content

telos_agent/tools/command_security/bash/
parser.rs

1//! Bash AST parser wrapper around `tree-sitter-bash`.
2//!
3//! Converts tree-sitter's raw [`tree_sitter::Node`] graph into a friendlier
4//! [`Node`] tree that the security analyzer consumes. All offsets are UTF-8
5//! byte offsets and [`Node::text`] is sliced directly from the original source
6//! so callers never have to manage tree-sitter lifetimes themselves.
7
8use std::collections::HashSet;
9
10/// A node in the bash AST.
11#[derive(Debug, Clone)]
12pub struct Node {
13    pub kind: String,
14    pub text: String,
15    pub start_byte: usize,
16    pub end_byte: usize,
17    pub children: Vec<Node>,
18}
19
20impl Node {
21    /// Find the first direct child with the given kind.
22    pub fn child(&self, kind: &str) -> Option<&Node> {
23        self.children.iter().find(|c| c.kind == kind)
24    }
25
26    /// Iterate over direct children of the given kinds.
27    pub fn children_of_kind(&self, kinds: &[&str]) -> impl Iterator<Item = &Node> {
28        let set: HashSet<String> = kinds.iter().map(|s| (*s).to_string()).collect();
29        self.children.iter().filter(move |c| set.contains(&c.kind))
30    }
31
32    /// Find the first descendant (depth-first) with the given kind.
33    pub fn find_descendant(&self, kind: &str) -> Option<&Node> {
34        for child in &self.children {
35            if child.kind == kind {
36                return Some(child);
37            }
38            if let Some(found) = child.find_descendant(kind) {
39                return Some(found);
40            }
41        }
42        None
43    }
44
45    /// True if any descendant has the given kind.
46    pub fn has_descendant(&self, kind: &str) -> bool {
47        self.find_descendant(kind).is_some()
48    }
49
50    /// Collect every descendant of the given kind.
51    pub fn collect_descendants<'a>(&'a self, kind: &str, out: &mut Vec<&'a Node>) {
52        for child in &self.children {
53            if child.kind == kind {
54                out.push(child);
55            }
56            child.collect_descendants(kind, out);
57        }
58    }
59}
60
61/// Parse a bash command string into an AST.
62///
63/// Returns `None` when tree-sitter fails to parse (e.g. malformed syntax) or
64/// when the grammar is unavailable.
65pub fn parse(source: &str) -> Option<Node> {
66    let mut parser = tree_sitter::Parser::new();
67    parser.set_language(&tree_sitter_bash::LANGUAGE.into()).ok()?;
68    let tree = parser.parse(source, None)?;
69    Some(convert_node(tree.root_node(), source.as_bytes()))
70}
71
72fn convert_node(ts_node: tree_sitter::Node, source: &[u8]) -> Node {
73    let start_byte = ts_node.start_byte();
74    let end_byte = ts_node.end_byte();
75    let text = String::from_utf8_lossy(&source[start_byte..end_byte]).to_string();
76    let children: Vec<Node> = (0..ts_node.child_count())
77        .filter_map(|i| {
78            let child = ts_node.child(i)?;
79            Some(convert_node(child, source))
80        })
81        .collect();
82
83    Node { kind: ts_node.kind().to_string(), text, start_byte, end_byte, children }
84}
85
86/// Named node kinds that represent compound / dynamic shell constructs.
87/// If any of these appear in a command, we refuse to extract a static argv.
88pub const DANGEROUS_KINDS: &[&str] = &[
89    "command_substitution", // $(...)
90    "process_substitution", // <(...) >(...)
91    "expansion",            // ${...}
92    "subshell",             // (...)
93    "compound_statement",   // { ...; }
94    "for_statement",
95    "while_statement",
96    "until_statement",
97    "if_statement",
98    "case_statement",
99    "function_definition",
100    "test_command",
101    "ansi_c_string",
102    "translated_string",
103    "herestring_redirect",
104    "heredoc_redirect",
105    "arithmetic_expansion", // $((...))
106];
107
108/// Kinds that wrap multiple commands.
109pub const STRUCTURAL_KINDS: &[&str] = &["program", "list", "pipeline", "redirected_statement"];
110
111/// A redirect operator canonical enum.
112#[derive(Debug, Clone, Copy, PartialEq, Eq)]
113pub enum RedirectOp {
114    Output,
115    Append,
116    Input,
117    HereDoc,
118    HereString,
119    DupOutput,
120    DupInput,
121    ForceOutput,
122    StderrStdout,
123    StderrAppend,
124}
125
126impl RedirectOp {
127    /// Returns true if the redirect can write to a file.
128    pub fn is_output(&self) -> bool {
129        matches!(
130            self,
131            RedirectOp::Output
132                | RedirectOp::Append
133                | RedirectOp::ForceOutput
134                | RedirectOp::StderrStdout
135                | RedirectOp::StderrAppend
136        )
137    }
138}
139
140/// Kinds that are considered literal words / arguments.
141pub const ARGUMENT_KINDS: &[&str] = &["word", "string", "raw_string", "number"];
142
143/// Returns true if the node kind is an argument-like token.
144pub fn is_argument_kind(kind: &str) -> bool {
145    ARGUMENT_KINDS.contains(&kind)
146}
147
148/// Returns true if the node kind is a command name or declaration command.
149pub fn is_command_kind(kind: &str) -> bool {
150    kind == "command" || kind == "declaration_command"
151}
152
153/// Maps a tree-sitter redirect operator kind to its canonical form.
154pub fn redirect_op_from_kind(
155    kind: &str,
156) -> Option<crate::tools::command_security::bash::RedirectOp> {
157    use crate::tools::command_security::bash::RedirectOp;
158    match kind {
159        ">" => Some(RedirectOp::Output),
160        ">>" => Some(RedirectOp::Append),
161        "<" => Some(RedirectOp::Input),
162        "<<" => Some(RedirectOp::HereDoc),
163        "<<<" => Some(RedirectOp::HereString),
164        ">&" | ">&-" => Some(RedirectOp::DupOutput),
165        "<&" | "<&-" => Some(RedirectOp::DupInput),
166        ">|" => Some(RedirectOp::ForceOutput),
167        "&>" => Some(RedirectOp::StderrStdout),
168        "&>>" => Some(RedirectOp::StderrAppend),
169        _ => None,
170    }
171}
172
173/// Simple field-name to value map for a `variable_assignment` node.
174pub fn parse_variable_assignment(node: &Node) -> Option<(String, String)> {
175    if node.kind != "variable_assignment" {
176        return None;
177    }
178    let name = node.child("variable_name")?;
179    let value =
180        node.children.iter().find(|c| c.kind == "word").map(|v| v.text.clone()).unwrap_or_default();
181    Some((name.text.clone(), value))
182}
183
184// ─────────────────────── SimpleCommand extraction helpers ───────────────────────
185
186/// A simple command with its argv, leading environment assignments, and redirects.
187#[derive(Debug, Clone, PartialEq, Eq)]
188pub struct SimpleCommand {
189    pub argv: Vec<String>,
190    pub env_vars: Vec<(String, String)>,
191    pub redirects: Vec<super::redirect::Redirect>,
192    pub text: String,
193}
194
195/// Collect all simple commands from an AST node.
196pub(crate) fn collect_commands(node: &Node) -> Vec<SimpleCommand> {
197    let mut out = Vec::new();
198    collect_commands_inner(node, Vec::new(), &mut out);
199    out
200}
201
202fn collect_commands_inner(
203    node: &Node,
204    pending_redirects: Vec<super::redirect::Redirect>,
205    out: &mut Vec<SimpleCommand>,
206) {
207    if is_command_kind(&node.kind) {
208        if let Some(cmd) = extract_simple_command(node, pending_redirects) {
209            out.push(cmd);
210        }
211        return;
212    }
213
214    if node.kind == "redirected_statement" {
215        let redirects = super::redirect::extract_redirects(node);
216        for child in &node.children {
217            if is_command_kind(&child.kind) {
218                collect_commands_inner(child, redirects.clone(), out);
219            }
220        }
221        return;
222    }
223
224    if STRUCTURAL_KINDS.contains(&node.kind.as_str()) || node.kind == "subshell" {
225        for child in &node.children {
226            collect_commands_inner(child, pending_redirects.clone(), out);
227        }
228    }
229}
230
231fn extract_simple_command(
232    node: &Node,
233    outer_redirects: Vec<super::redirect::Redirect>,
234) -> Option<SimpleCommand> {
235    let text = node.text.clone();
236    let mut argv = Vec::new();
237    let mut env_vars = Vec::new();
238
239    if node.kind == "declaration_command" {
240        let first = node.children.first()?;
241        argv.push(first.text.clone());
242        return Some(SimpleCommand { argv, env_vars, redirects: outer_redirects, text });
243    }
244
245    let mut found_command_name = false;
246
247    for child in &node.children {
248        match child.kind.as_str() {
249            "variable_assignment" => {
250                if let Some((name, value)) = parse_variable_assignment(child) {
251                    env_vars.push((name, value));
252                }
253            }
254            "command_name" => {
255                found_command_name = true;
256                argv.push(strip_quotes(&child.text));
257            }
258            kind if is_argument_kind(kind) => {
259                if !found_command_name {
260                    found_command_name = true;
261                    argv.push(strip_quotes(&child.text));
262                } else {
263                    argv.push(strip_quotes(&child.text));
264                }
265            }
266            _ => {}
267        }
268    }
269
270    if argv.is_empty() {
271        return None;
272    }
273
274    Some(SimpleCommand { argv, env_vars, redirects: outer_redirects, text })
275}
276
277pub(crate) fn strip_quotes(text: &str) -> String {
278    if text.len() >= 2
279        && ((text.starts_with('"') && text.ends_with('"'))
280            || (text.starts_with('\'') && text.ends_with('\'')))
281    {
282        text[1..text.len() - 1].to_string()
283    } else {
284        text.to_string()
285    }
286}
287
288pub(crate) fn has_glob_or_brace_expansion(node: &Node) -> bool {
289    if node.kind == "word" {
290        return is_glob_pattern(&node.text);
291    }
292    if node.kind == "concatenation" {
293        return is_brace_expansion(&node.text)
294            || node.children.iter().any(has_glob_or_brace_expansion);
295    }
296    node.children.iter().any(has_glob_or_brace_expansion)
297}
298
299fn is_glob_pattern(text: &str) -> bool {
300    text.contains(['*', '?', '['])
301}
302
303fn is_brace_expansion(text: &str) -> bool {
304    let mut depth = 0i32;
305    let mut has_comma_or_range = false;
306    for c in text.chars() {
307        match c {
308            '{' => depth += 1,
309            '}' => {
310                if depth > 0 && has_comma_or_range {
311                    return true;
312                }
313                depth = (depth - 1).max(0);
314                has_comma_or_range = false;
315            }
316            ',' if depth > 0 => has_comma_or_range = true,
317            _ => {}
318        }
319    }
320    false
321}
322
323#[cfg(test)]
324mod tests {
325    use super::*;
326
327    #[test]
328    fn parses_simple_command() {
329        let ast = parse("echo hello").unwrap();
330        assert_eq!(ast.kind, "program");
331        assert!(ast.has_descendant("command"));
332    }
333
334    #[test]
335    fn detects_command_substitution() {
336        let ast = parse("echo $(rm -rf /)").unwrap();
337        assert!(ast.has_descendant("command_substitution"));
338    }
339
340    #[test]
341    fn detects_pipeline() {
342        let ast = parse("cat file | sh").unwrap();
343        assert!(ast.has_descendant("pipeline"));
344    }
345}