telos_agent/tools/command_security/bash/
parser.rs1use std::collections::HashSet;
9
10#[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 pub fn child(&self, kind: &str) -> Option<&Node> {
23 self.children.iter().find(|c| c.kind == kind)
24 }
25
26 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 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 pub fn has_descendant(&self, kind: &str) -> bool {
47 self.find_descendant(kind).is_some()
48 }
49
50 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
61pub 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
86pub const DANGEROUS_KINDS: &[&str] = &[
89 "command_substitution", "process_substitution", "expansion", "subshell", "compound_statement", "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", ];
107
108pub const STRUCTURAL_KINDS: &[&str] = &["program", "list", "pipeline", "redirected_statement"];
110
111#[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 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
140pub const ARGUMENT_KINDS: &[&str] = &["word", "string", "raw_string", "number"];
142
143pub fn is_argument_kind(kind: &str) -> bool {
145 ARGUMENT_KINDS.contains(&kind)
146}
147
148pub fn is_command_kind(kind: &str) -> bool {
150 kind == "command" || kind == "declaration_command"
151}
152
153pub 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
173pub 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#[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
195pub(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}