1use std::borrow::Cow;
2
3use tree_sitter::{Node, Parser};
4
5use crate::errors::{Result, WinxError};
6
7fn 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
30pub 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 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 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
87fn 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 let Some(bash) = crate::utils::executable::bash_executable() else { return false };
100 std::process::Command::new(bash)
101 .arg("-n")
102 .arg("-c")
103 .arg(command)
104 .stdin(std::process::Stdio::null())
105 .stdout(std::process::Stdio::null())
106 .stderr(std::process::Stdio::null())
107 .status()
108 .is_ok_and(|status| status.success())
109}
110
111#[derive(Debug, Clone)]
112struct StatementNode {
113 kind: String,
114 start_byte: usize,
115 end_byte: usize,
116}
117
118fn top_level_statement_count(source: &str, root: Node<'_>) -> usize {
119 let mut statements = Vec::new();
120 collect_statement_nodes(root, &mut statements);
121
122 statements
123 .iter()
124 .filter(|stmt| stmt.kind != "comment")
125 .filter(|stmt| !statements.iter().any(|other| is_contained_statement(source, stmt, other)))
126 .count()
127}
128
129fn collect_statement_nodes(node: Node<'_>, statements: &mut Vec<StatementNode>) {
130 if is_statement_node(node.kind()) {
131 statements.push(StatementNode {
132 kind: node.kind().to_string(),
133 start_byte: node.start_byte(),
134 end_byte: node.end_byte(),
135 });
136 }
137
138 let child_count = u32::try_from(node.named_child_count()).unwrap_or(u32::MAX);
139 for index in 0..child_count {
140 if let Some(child) = node.named_child(index) {
141 collect_statement_nodes(child, statements);
142 }
143 }
144}
145
146fn is_statement_node(kind: &str) -> bool {
147 matches!(
148 kind,
149 "command"
150 | "variable_assignment"
151 | "declaration_command"
152 | "unset_command"
153 | "comment"
154 | "for_statement"
155 | "c_style_for_statement"
156 | "while_statement"
157 | "if_statement"
158 | "case_statement"
159 | "function_definition"
160 | "pipeline"
161 | "list"
162 | "compound_statement"
163 | "subshell"
164 | "redirected_statement"
165 )
166}
167
168fn is_contained_statement(source: &str, stmt: &StatementNode, other: &StatementNode) -> bool {
169 if stmt.start_byte == other.start_byte
170 && stmt.end_byte == other.end_byte
171 && stmt.kind == other.kind
172 {
173 return false;
174 }
175
176 let other_text = &source[other.start_byte..other.end_byte];
177 if other.kind == "list" && other_text.contains(';') {
178 return false;
179 }
180
181 other.start_byte <= stmt.start_byte
182 && other.end_byte >= stmt.end_byte
183 && other.end_byte - other.start_byte > stmt.end_byte - stmt.start_byte
184 && other_text.contains(&source[stmt.start_byte..stmt.end_byte])
185}
186
187pub fn extract_command_texts(command: &str) -> Result<Vec<String>> {
199 let trimmed = command.trim();
200 if trimmed.is_empty() {
201 return Ok(Vec::new());
202 }
203 if trimmed.contains('\0') {
204 return Err(WinxError::CommandExecutionError("Command contains a NUL byte.".to_string()));
205 }
206
207 let parse_src = neutralize_supplementary(trimmed);
212
213 let mut parser = Parser::new();
214 let language: tree_sitter::Language = tree_sitter_bash::LANGUAGE.into();
215 parser.set_language(&language).map_err(|error| {
216 WinxError::CommandExecutionError(format!("Failed to load bash parser: {error}"))
217 })?;
218
219 let tree = parser.parse(parse_src.as_ref(), None).ok_or_else(|| {
220 WinxError::CommandExecutionError("Failed to parse bash command".to_string())
221 })?;
222 let root = tree.root_node();
223 if root.has_error() {
224 return Err(WinxError::CommandExecutionError(
225 "Command could not be parsed for allowlist enforcement.".to_string(),
226 ));
227 }
228
229 let mut texts = Vec::new();
230 collect_command_texts(root, parse_src.as_ref().as_bytes(), &mut texts);
231 Ok(texts)
232}
233
234pub fn extract_static_write_paths(command: &str) -> Vec<String> {
241 let trimmed = command.trim();
242 if trimmed.is_empty() || trimmed.contains('\0') {
243 return Vec::new();
244 }
245
246 let parse_src = neutralize_supplementary(trimmed);
247 let mut parser = Parser::new();
248 let language: tree_sitter::Language = tree_sitter_bash::LANGUAGE.into();
249 if parser.set_language(&language).is_err() {
250 return Vec::new();
251 }
252 let Some(tree) = parser.parse(parse_src.as_ref(), None) else { return Vec::new() };
253
254 let mut paths = Vec::new();
255 collect_static_write_paths(tree.root_node(), parse_src.as_ref().as_bytes(), &mut paths);
256 paths.sort();
257 paths.dedup();
258 paths
259}
260
261fn collect_static_write_paths(node: Node<'_>, src: &[u8], out: &mut Vec<String>) {
262 match node.kind() {
263 "file_redirect" => collect_redirect_destination(node, src, out),
264 "command" => collect_command_destinations(node, src, out),
265 _ => {}
266 }
267
268 let mut cursor = node.walk();
269 for child in node.children(&mut cursor) {
270 collect_static_write_paths(child, src, out);
271 }
272}
273
274fn collect_redirect_destination(node: Node<'_>, src: &[u8], out: &mut Vec<String>) {
275 let Some(destination) = node.child_by_field_name("destination") else { return };
276 let prefix_end = destination.start_byte().saturating_sub(node.start_byte());
277 let Ok(redirect) = node.utf8_text(src) else { return };
278 let prefix = redirect.get(..prefix_end).unwrap_or(redirect);
279 if !prefix.contains('>') {
280 return;
281 }
282 if let Ok(text) = destination.utf8_text(src) {
283 push_static_shell_word(text, out);
284 }
285}
286
287fn collect_command_destinations(node: Node<'_>, src: &[u8], out: &mut Vec<String>) {
288 let Some(name) = node.child_by_field_name("name") else { return };
289 let Ok(name) = name.utf8_text(src) else { return };
290 let Some(name) = static_shell_word(name) else { return };
291 let name = std::path::Path::new(&name)
292 .file_name()
293 .and_then(std::ffi::OsStr::to_str)
294 .unwrap_or(name.as_str());
295
296 let mut cursor = node.walk();
297 let arguments = node
298 .children_by_field_name("argument", &mut cursor)
299 .filter_map(|argument| argument.utf8_text(src).ok())
300 .filter_map(static_shell_word)
301 .collect::<Vec<_>>();
302 let positional =
303 arguments.iter().filter(|argument| !argument.starts_with('-')).cloned().collect::<Vec<_>>();
304
305 match name {
306 "tee" | "touch" | "truncate" | "mkdir" | "mkfifo" => {
307 out.extend(positional);
308 }
309 "cp" | "mv" | "install" | "ln" => {
310 if let Some(destination) = positional.last() {
311 out.push(destination.clone());
312 }
313 for argument in &arguments {
314 if let Some(destination) = argument.strip_prefix("--target-directory=") {
315 out.push(destination.to_string());
316 }
317 }
318 }
319 "dd" => {
320 for argument in &arguments {
321 if let Some(destination) = argument.strip_prefix("of=") {
322 out.push(destination.to_string());
323 }
324 }
325 }
326 "sed" | "perl"
327 if arguments.iter().any(|argument| {
328 argument == "-i"
329 || argument.starts_with("-i")
330 || argument == "-pi"
331 || argument.starts_with("-pi")
332 }) =>
333 {
334 out.extend(positional);
335 }
336 _ => {}
337 }
338}
339
340fn push_static_shell_word(text: &str, out: &mut Vec<String>) {
341 if let Some(word) = static_shell_word(text) {
342 out.push(word);
343 }
344}
345
346fn static_shell_word(text: &str) -> Option<String> {
347 let text = text.trim();
348 if text.is_empty() {
349 return None;
350 }
351
352 let bytes = text.as_bytes();
353 let unquoted = if bytes.len() >= 2
354 && matches!(
355 (bytes.first(), bytes.last()),
356 (Some(b'\''), Some(b'\'')) | (Some(b'"'), Some(b'"'))
357 ) {
358 &text[1..text.len() - 1]
359 } else {
360 text
361 };
362 let known_temp_path = unquoted == "$WINX_TEMP_DIR"
363 || unquoted == "${WINX_TEMP_DIR}"
364 || unquoted.starts_with("$WINX_TEMP_DIR/")
365 || unquoted.starts_with("${WINX_TEMP_DIR}/");
366 if unquoted.is_empty()
367 || unquoted.contains('`')
368 || unquoted.contains('*')
369 || unquoted.contains('?')
370 || (unquoted.contains('$') && !known_temp_path)
371 || (known_temp_path
372 && unquoted
373 .trim_start_matches("${WINX_TEMP_DIR}")
374 .trim_start_matches("$WINX_TEMP_DIR")
375 .contains('$'))
376 {
377 None
378 } else {
379 Some(unquoted.replace("\\ ", " ").replace("\\\"", "\"").replace("\\'", "'"))
380 }
381}
382
383fn collect_command_texts(node: Node<'_>, src: &[u8], out: &mut Vec<String>) {
384 if node.kind() == "command" {
385 if let Ok(text) = node.utf8_text(src) {
386 out.push(text.to_string());
387 }
388 }
389 let mut cursor = node.walk();
390 for child in node.children(&mut cursor) {
391 collect_command_texts(child, src, out);
392 }
393}
394
395pub const SHELL_SPAWNING_COMMANDS: &[&str] = &[
403 "bash", "sh", "zsh", "dash", "ksh", "fish", "csh", "tcsh", "eval", "source", ".", "env",
404 "xargs", "nice", "nohup", "timeout", "find", "watch", "sudo", "su", "ssh", "python", "python3",
405 "perl", "ruby", "node", "deno", "awk", "gawk", "php", "lua",
406];
407
408pub const ARCHITECT_COMMANDS: &[&str] = &[
413 "pwd",
414 "ls",
415 "rg",
416 "grep",
417 "head",
418 "tail",
419 "wc",
420 "stat",
421 "file",
422 "readlink",
423 "realpath",
424 "basename",
425 "dirname",
426 "printf",
427 "echo",
428 "type",
429 "which",
430 "uname",
431 "date",
432 "id",
433 "whoami",
434 "du",
435 "df",
436 "tree",
437 "git status",
438 "git diff",
439 "git log",
440 "git show",
441 "git ls-files",
442 "git grep",
443 "git rev-parse",
444 "git branch --show-current",
445];
446
447pub fn architect_allowed_commands() -> Vec<String> {
448 ARCHITECT_COMMANDS.iter().map(|command| (*command).to_string()).collect()
449}
450
451pub fn is_architect_command_allowed(command_line: &str) -> bool {
458 if contains_file_redirect(command_line) {
459 return false;
460 }
461 let Ok(commands) = extract_command_texts(command_line) else { return false };
462 !commands.is_empty() && commands.iter().all(|command| architect_command_is_safe(command))
463}
464
465fn contains_file_redirect(command: &str) -> bool {
469 let parse_src = neutralize_supplementary(command.trim());
470 let mut parser = Parser::new();
471 let language: tree_sitter::Language = tree_sitter_bash::LANGUAGE.into();
472 if parser.set_language(&language).is_err() {
473 return true;
474 }
475 let Some(tree) = parser.parse(parse_src.as_ref(), None) else { return true };
476 let root = tree.root_node();
477 root.has_error() || node_tree_contains_kind(root, "file_redirect")
478}
479
480fn node_tree_contains_kind(node: Node<'_>, kind: &str) -> bool {
481 if node.kind() == kind {
482 return true;
483 }
484 let mut cursor = node.walk();
485 let found = node.children(&mut cursor).any(|child| node_tree_contains_kind(child, kind));
486 found
487}
488
489fn architect_command_is_safe(command: &str) -> bool {
490 let words: Vec<&str> = command.split_whitespace().collect();
491 let Some(program) = words.first().copied() else { return false };
492
493 if program == "git" {
494 let Some(subcommand) = words.get(1).copied() else { return false };
495 if subcommand == "branch" {
496 return words.as_slice() == ["git", "branch", "--show-current"];
497 }
498 if !matches!(
499 subcommand,
500 "status" | "diff" | "log" | "show" | "ls-files" | "grep" | "rev-parse"
501 ) {
502 return false;
503 }
504 return !words.iter().skip(2).any(|word| {
505 word.starts_with("--output")
506 || *word == "--ext-diff"
507 || *word == "--textconv"
508 || word.starts_with("--open-files-in-pager")
509 });
510 }
511
512 let allowed = ARCHITECT_COMMANDS
513 .iter()
514 .filter(|entry| !entry.starts_with("git "))
515 .any(|entry| *entry == program);
516 if !allowed {
517 return false;
518 }
519
520 if program == "rg" {
521 return !words.iter().skip(1).any(|word| {
522 word == &"--pre"
523 || word.starts_with("--pre=")
524 || word == &"--hostname-bin"
525 || word.starts_with("--hostname-bin=")
526 });
527 }
528
529 true
530}
531
532pub fn detect_allowlist_bypass(allowed: &[String]) -> Vec<String> {
539 let mut hits: Vec<String> = allowed
540 .iter()
541 .filter_map(|entry| {
542 let first = entry.split_whitespace().next()?;
543 let base = std::path::Path::new(first).file_name()?.to_str()?;
544 SHELL_SPAWNING_COMMANDS.contains(&base).then(|| base.to_string())
545 })
546 .collect();
547 hits.sort();
548 hits.dedup();
549 hits
550}
551
552#[cfg(test)]
553mod tests {
554 use super::assert_single_statement;
555 use super::detect_allowlist_bypass;
556 use super::extract_command_texts;
557 use super::extract_static_write_paths;
558 use super::is_architect_command_allowed;
559 use proptest::prelude::*;
560
561 proptest! {
562 #[test]
566 fn assert_single_statement_never_panics(cmd in "[\\s\\S]{0,80}") {
567 let _ = assert_single_statement(&cmd, false);
568 let _ = assert_single_statement(&cmd, true);
569 }
570
571 #[test]
572 fn extract_command_texts_never_panics(cmd in "[\\s\\S]{0,80}") {
573 let _ = extract_command_texts(&cmd);
574 }
575
576 #[test]
577 fn extract_static_write_paths_never_panics(cmd in "[\\s\\S]{0,80}") {
578 let _ = extract_static_write_paths(&cmd);
579 }
580 }
581
582 #[test]
583 fn supplementary_plane_input_does_not_segfault_the_parser() {
584 for c in ['\u{10FFFF}', '\u{4E980}', '\u{1F574}', '\u{100000}'] {
590 for _ in 0..40 {
591 let cmd = format!("{{{c}");
592 let _ = assert_single_statement(&cmd, false);
593 let _ = extract_command_texts(&cmd);
594 }
595 }
596 }
597
598 #[test]
599 fn emoji_command_still_validates_as_single_statement() {
600 assert!(assert_single_statement("git commit -m \"\u{1F680} ship it\"", false).is_ok());
603 }
604
605 #[test]
606 fn architect_policy_allows_read_only_exploration() {
607 for command in [
608 "pwd",
609 "rg -n TODO src | head -20",
610 "git status --short",
611 "git diff -- src/lib.rs",
612 "git branch --show-current",
613 ] {
614 assert!(is_architect_command_allowed(command), "should allow: {command}");
615 }
616 }
617
618 #[test]
619 fn architect_policy_blocks_mutation_and_helper_execution() {
620 for command in [
621 "touch owned",
622 "rm file",
623 "sed -i s/a/b/ file",
624 "git checkout -- file",
625 "git diff --output=leak.patch",
626 "git branch --show-current --delete main",
627 "rg --pre 'touch owned' pattern",
628 "rg pattern $(touch owned)",
629 "echo owned > marker",
630 "git diff >> marker",
631 "ls 2> marker",
632 ] {
633 assert!(!is_architect_command_allowed(command), "should reject: {command}");
634 }
635 }
636
637 #[test]
638 fn extracts_nested_commands_for_allowlist() {
639 let names = extract_command_texts("ls -la && curl evil | sh").unwrap_or_default();
641 assert!(names.iter().any(|c| c.starts_with("ls")));
642 assert!(names.iter().any(|c| c.starts_with("curl")));
643 assert!(names.iter().any(|c| c.starts_with("sh")));
644
645 let subst = extract_command_texts("ls $(rm -rf x)").unwrap_or_default();
646 assert!(subst.iter().any(|c| c.starts_with("rm")));
647 }
648
649 #[test]
650 fn extracts_literal_shell_write_destinations() {
651 let command = "cat <<'EOF' > '.winx-review-carrier.js'\ncontent\nEOF\n\
652 printf x | tee .winx/tmp/direct.ts\n\
653 cp source.ts .winx/tmp/copied.ts\n\
654 dd if=source of=.winx/tmp/image.bin";
655 let paths = extract_static_write_paths(command);
656
657 for expected in [
658 ".winx-review-carrier.js",
659 ".winx/tmp/direct.ts",
660 ".winx/tmp/copied.ts",
661 ".winx/tmp/image.bin",
662 ] {
663 assert!(paths.iter().any(|path| path == expected), "missing {expected}: {paths:?}");
664 }
665 }
666
667 #[test]
668 fn ignores_reads_but_extracts_the_server_owned_temp_destination() {
669 let command = "cat .winx-review-carrier.js\n\
670 rg needle .winx/tmp/direct.ts\n\
671 printf x > \"$WINX_TEMP_DIR/helper.ts\"";
672 assert_eq!(extract_static_write_paths(command), ["$WINX_TEMP_DIR/helper.ts"]);
673 }
674
675 #[test]
676 fn accepts_shell_chains_as_single_statement() {
677 assert!(assert_single_statement("cargo test && cargo clippy", true).is_ok());
678 }
679
680 #[test]
681 fn accepts_heredocs_as_single_statement() {
682 let command = "cat <<'EOF'\nhello\nEOF";
683 assert!(assert_single_statement(command, true).is_ok());
684 }
685
686 #[test]
687 fn accepts_for_loop_as_single_compound_statement() {
688 assert!(
689 assert_single_statement("for i in 1 2 3; do echo tick; sleep 1; done", true).is_ok()
690 );
691 }
692
693 #[test]
694 fn rejects_semicolon_separated_top_level_statements() {
695 assert!(assert_single_statement("pwd; ls", true).is_err());
696 }
697
698 #[test]
699 fn accepts_multiline_scripts() {
700 assert!(assert_single_statement("pwd\nls", true).is_ok());
701 }
702
703 #[test]
704 fn accepts_bash_lc_script_when_tree_sitter_reports_error() {
705 let command = "bash -lc 'printf \"%s\\n\" \"-- drm connectors --\"; for s in /sys/class/drm/card*-*/status; do [ -e \"$s\" ] || continue; c=${s%/status}; printf \"%s: %s\" \"${c##*/}\" \"$(cat \"$s\")\"; done'";
706 assert!(assert_single_statement(command, true).is_ok());
707 }
708
709 #[cfg(unix)] #[test]
711 fn shell_probe_is_gated_to_trusted_mode() {
712 use super::rescued_by_shell_probe;
713 assert!(rescued_by_shell_probe("echo hi", true));
719 assert!(!rescued_by_shell_probe("echo hi", false));
722 assert!(!rescued_by_shell_probe("echo )(", true));
724 }
725
726 #[test]
727 fn detect_allowlist_bypass_flags_shell_spawners() {
728 let allowed = vec![
729 "ls".to_string(),
730 "bash".to_string(),
731 "cat -n".to_string(),
732 "find . -exec rm {} +".to_string(),
733 ];
734 assert_eq!(detect_allowlist_bypass(&allowed), vec!["bash".to_string(), "find".to_string()]);
735 }
736
737 #[test]
738 fn detect_allowlist_bypass_clean_list_is_empty() {
739 let allowed = vec!["ls".to_string(), "cat".to_string(), "grep -n foo".to_string()];
740 assert!(detect_allowlist_bypass(&allowed).is_empty());
741 }
742
743 #[test]
744 fn detect_allowlist_bypass_matches_basename_of_path() {
745 let allowed = vec!["/usr/bin/env python".to_string()];
746 assert_eq!(detect_allowlist_bypass(&allowed), vec!["env".to_string()]);
747 }
748
749 #[test]
750 fn rejects_nul_with_actionable_message() {
751 let error = match assert_single_statement("printf '\0'", true) {
752 Ok(()) => String::new(),
753 Err(error) => error.to_string(),
754 };
755 assert!(error.contains("NUL byte"));
756 assert!(error.contains("\\\\x00"));
757 }
758}