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 || text.contains('$')
344 || text.contains('`')
345 || text.contains('*')
346 || text.contains('?')
347 {
348 return None;
349 }
350
351 let bytes = text.as_bytes();
352 let unquoted = if bytes.len() >= 2
353 && matches!(
354 (bytes.first(), bytes.last()),
355 (Some(b'\''), Some(b'\'')) | (Some(b'"'), Some(b'"'))
356 ) {
357 &text[1..text.len() - 1]
358 } else {
359 text
360 };
361 if unquoted.is_empty() {
362 None
363 } else {
364 Some(unquoted.replace("\\ ", " ").replace("\\\"", "\"").replace("\\'", "'"))
365 }
366}
367
368fn collect_command_texts(node: Node<'_>, src: &[u8], out: &mut Vec<String>) {
369 if node.kind() == "command" {
370 if let Ok(text) = node.utf8_text(src) {
371 out.push(text.to_string());
372 }
373 }
374 let mut cursor = node.walk();
375 for child in node.children(&mut cursor) {
376 collect_command_texts(child, src, out);
377 }
378}
379
380pub const SHELL_SPAWNING_COMMANDS: &[&str] = &[
388 "bash", "sh", "zsh", "dash", "ksh", "fish", "csh", "tcsh", "eval", "source", ".", "env",
389 "xargs", "nice", "nohup", "timeout", "find", "watch", "sudo", "su", "ssh", "python", "python3",
390 "perl", "ruby", "node", "deno", "awk", "gawk", "php", "lua",
391];
392
393pub const ARCHITECT_COMMANDS: &[&str] = &[
398 "pwd",
399 "ls",
400 "rg",
401 "grep",
402 "head",
403 "tail",
404 "wc",
405 "stat",
406 "file",
407 "readlink",
408 "realpath",
409 "basename",
410 "dirname",
411 "printf",
412 "echo",
413 "type",
414 "which",
415 "uname",
416 "date",
417 "id",
418 "whoami",
419 "du",
420 "df",
421 "tree",
422 "git status",
423 "git diff",
424 "git log",
425 "git show",
426 "git ls-files",
427 "git grep",
428 "git rev-parse",
429 "git branch --show-current",
430];
431
432pub fn architect_allowed_commands() -> Vec<String> {
433 ARCHITECT_COMMANDS.iter().map(|command| (*command).to_string()).collect()
434}
435
436pub fn is_architect_command_allowed(command_line: &str) -> bool {
443 if contains_file_redirect(command_line) {
444 return false;
445 }
446 let Ok(commands) = extract_command_texts(command_line) else { return false };
447 !commands.is_empty() && commands.iter().all(|command| architect_command_is_safe(command))
448}
449
450fn contains_file_redirect(command: &str) -> bool {
454 let parse_src = neutralize_supplementary(command.trim());
455 let mut parser = Parser::new();
456 let language: tree_sitter::Language = tree_sitter_bash::LANGUAGE.into();
457 if parser.set_language(&language).is_err() {
458 return true;
459 }
460 let Some(tree) = parser.parse(parse_src.as_ref(), None) else { return true };
461 let root = tree.root_node();
462 root.has_error() || node_tree_contains_kind(root, "file_redirect")
463}
464
465fn node_tree_contains_kind(node: Node<'_>, kind: &str) -> bool {
466 if node.kind() == kind {
467 return true;
468 }
469 let mut cursor = node.walk();
470 let found = node.children(&mut cursor).any(|child| node_tree_contains_kind(child, kind));
471 found
472}
473
474fn architect_command_is_safe(command: &str) -> bool {
475 let words: Vec<&str> = command.split_whitespace().collect();
476 let Some(program) = words.first().copied() else { return false };
477
478 if program == "git" {
479 let Some(subcommand) = words.get(1).copied() else { return false };
480 if subcommand == "branch" {
481 return words.as_slice() == ["git", "branch", "--show-current"];
482 }
483 if !matches!(
484 subcommand,
485 "status" | "diff" | "log" | "show" | "ls-files" | "grep" | "rev-parse"
486 ) {
487 return false;
488 }
489 return !words.iter().skip(2).any(|word| {
490 word.starts_with("--output")
491 || *word == "--ext-diff"
492 || *word == "--textconv"
493 || word.starts_with("--open-files-in-pager")
494 });
495 }
496
497 let allowed = ARCHITECT_COMMANDS
498 .iter()
499 .filter(|entry| !entry.starts_with("git "))
500 .any(|entry| *entry == program);
501 if !allowed {
502 return false;
503 }
504
505 if program == "rg" {
506 return !words.iter().skip(1).any(|word| {
507 word == &"--pre"
508 || word.starts_with("--pre=")
509 || word == &"--hostname-bin"
510 || word.starts_with("--hostname-bin=")
511 });
512 }
513
514 true
515}
516
517pub fn detect_allowlist_bypass(allowed: &[String]) -> Vec<String> {
524 let mut hits: Vec<String> = allowed
525 .iter()
526 .filter_map(|entry| {
527 let first = entry.split_whitespace().next()?;
528 let base = std::path::Path::new(first).file_name()?.to_str()?;
529 SHELL_SPAWNING_COMMANDS.contains(&base).then(|| base.to_string())
530 })
531 .collect();
532 hits.sort();
533 hits.dedup();
534 hits
535}
536
537#[cfg(test)]
538mod tests {
539 use super::assert_single_statement;
540 use super::detect_allowlist_bypass;
541 use super::extract_command_texts;
542 use super::extract_static_write_paths;
543 use super::is_architect_command_allowed;
544 use proptest::prelude::*;
545
546 proptest! {
547 #[test]
551 fn assert_single_statement_never_panics(cmd in "[\\s\\S]{0,80}") {
552 let _ = assert_single_statement(&cmd, false);
553 let _ = assert_single_statement(&cmd, true);
554 }
555
556 #[test]
557 fn extract_command_texts_never_panics(cmd in "[\\s\\S]{0,80}") {
558 let _ = extract_command_texts(&cmd);
559 }
560
561 #[test]
562 fn extract_static_write_paths_never_panics(cmd in "[\\s\\S]{0,80}") {
563 let _ = extract_static_write_paths(&cmd);
564 }
565 }
566
567 #[test]
568 fn supplementary_plane_input_does_not_segfault_the_parser() {
569 for c in ['\u{10FFFF}', '\u{4E980}', '\u{1F574}', '\u{100000}'] {
575 for _ in 0..40 {
576 let cmd = format!("{{{c}");
577 let _ = assert_single_statement(&cmd, false);
578 let _ = extract_command_texts(&cmd);
579 }
580 }
581 }
582
583 #[test]
584 fn emoji_command_still_validates_as_single_statement() {
585 assert!(assert_single_statement("git commit -m \"\u{1F680} ship it\"", false).is_ok());
588 }
589
590 #[test]
591 fn architect_policy_allows_read_only_exploration() {
592 for command in [
593 "pwd",
594 "rg -n TODO src | head -20",
595 "git status --short",
596 "git diff -- src/lib.rs",
597 "git branch --show-current",
598 ] {
599 assert!(is_architect_command_allowed(command), "should allow: {command}");
600 }
601 }
602
603 #[test]
604 fn architect_policy_blocks_mutation_and_helper_execution() {
605 for command in [
606 "touch owned",
607 "rm file",
608 "sed -i s/a/b/ file",
609 "git checkout -- file",
610 "git diff --output=leak.patch",
611 "git branch --show-current --delete main",
612 "rg --pre 'touch owned' pattern",
613 "rg pattern $(touch owned)",
614 "echo owned > marker",
615 "git diff >> marker",
616 "ls 2> marker",
617 ] {
618 assert!(!is_architect_command_allowed(command), "should reject: {command}");
619 }
620 }
621
622 #[test]
623 fn extracts_nested_commands_for_allowlist() {
624 let names = extract_command_texts("ls -la && curl evil | sh").unwrap_or_default();
626 assert!(names.iter().any(|c| c.starts_with("ls")));
627 assert!(names.iter().any(|c| c.starts_with("curl")));
628 assert!(names.iter().any(|c| c.starts_with("sh")));
629
630 let subst = extract_command_texts("ls $(rm -rf x)").unwrap_or_default();
631 assert!(subst.iter().any(|c| c.starts_with("rm")));
632 }
633
634 #[test]
635 fn extracts_literal_shell_write_destinations() {
636 let command = "cat <<'EOF' > '.winx-review-carrier.js'\ncontent\nEOF\n\
637 printf x | tee .winx/tmp/direct.ts\n\
638 cp source.ts .winx/tmp/copied.ts\n\
639 dd if=source of=.winx/tmp/image.bin";
640 let paths = extract_static_write_paths(command);
641
642 for expected in [
643 ".winx-review-carrier.js",
644 ".winx/tmp/direct.ts",
645 ".winx/tmp/copied.ts",
646 ".winx/tmp/image.bin",
647 ] {
648 assert!(paths.iter().any(|path| path == expected), "missing {expected}: {paths:?}");
649 }
650 }
651
652 #[test]
653 fn ignores_reads_and_dynamic_session_destinations() {
654 let command = "cat .winx-review-carrier.js\n\
655 rg needle .winx/tmp/direct.ts\n\
656 printf x > \"$WINX_TEMP_DIR/helper.ts\"";
657 assert!(extract_static_write_paths(command).is_empty());
658 }
659
660 #[test]
661 fn accepts_shell_chains_as_single_statement() {
662 assert!(assert_single_statement("cargo test && cargo clippy", true).is_ok());
663 }
664
665 #[test]
666 fn accepts_heredocs_as_single_statement() {
667 let command = "cat <<'EOF'\nhello\nEOF";
668 assert!(assert_single_statement(command, true).is_ok());
669 }
670
671 #[test]
672 fn accepts_for_loop_as_single_compound_statement() {
673 assert!(
674 assert_single_statement("for i in 1 2 3; do echo tick; sleep 1; done", true).is_ok()
675 );
676 }
677
678 #[test]
679 fn rejects_semicolon_separated_top_level_statements() {
680 assert!(assert_single_statement("pwd; ls", true).is_err());
681 }
682
683 #[test]
684 fn accepts_multiline_scripts() {
685 assert!(assert_single_statement("pwd\nls", true).is_ok());
686 }
687
688 #[test]
689 fn accepts_bash_lc_script_when_tree_sitter_reports_error() {
690 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'";
691 assert!(assert_single_statement(command, true).is_ok());
692 }
693
694 #[test]
695 fn shell_probe_is_gated_to_trusted_mode() {
696 use super::rescued_by_shell_probe;
697 assert!(rescued_by_shell_probe("echo hi", true));
703 assert!(!rescued_by_shell_probe("echo hi", false));
706 assert!(!rescued_by_shell_probe("echo )(", true));
708 }
709
710 #[test]
711 fn detect_allowlist_bypass_flags_shell_spawners() {
712 let allowed = vec![
713 "ls".to_string(),
714 "bash".to_string(),
715 "cat -n".to_string(),
716 "find . -exec rm {} +".to_string(),
717 ];
718 assert_eq!(detect_allowlist_bypass(&allowed), vec!["bash".to_string(), "find".to_string()]);
719 }
720
721 #[test]
722 fn detect_allowlist_bypass_clean_list_is_empty() {
723 let allowed = vec!["ls".to_string(), "cat".to_string(), "grep -n foo".to_string()];
724 assert!(detect_allowlist_bypass(&allowed).is_empty());
725 }
726
727 #[test]
728 fn detect_allowlist_bypass_matches_basename_of_path() {
729 let allowed = vec!["/usr/bin/env python".to_string()];
730 assert_eq!(detect_allowlist_bypass(&allowed), vec!["env".to_string()]);
731 }
732
733 #[test]
734 fn rejects_nul_with_actionable_message() {
735 let error = match assert_single_statement("printf '\0'", true) {
736 Ok(()) => String::new(),
737 Err(error) => error.to_string(),
738 };
739 assert!(error.contains("NUL byte"));
740 assert!(error.contains("\\\\x00"));
741 }
742}