1use crate::capability::{CapabilityError, Context, Output, TypedCapability};
66use crate::config::RuntimoConfig;
67use crate::validation::path::{validate_path, PathContext};
68use crate::{Error, Result};
69use serde::{Deserialize, Serialize};
70use serde_json::Value;
71use std::fs;
72use std::io::{Read, Write};
73use std::os::unix::process::CommandExt;
74use std::process::{Child, Command, ExitStatus};
75use std::thread;
76use std::time::{Duration, Instant};
77
78type WaitResult = Result<(ExitStatus, Vec<u8>, Vec<u8>, Vec<u32>)>;
79
80const DEFAULT_TIMEOUT_SECS: u64 = 30;
81const MAX_OUTPUT_BYTES: usize = 10 * 1024 * 1024;
82const MAX_STDIN_BYTES: usize = 1024 * 1024;
83
84const SENSITIVE_ENV_PREFIXES: &[&str] = &[
92 "RUSTIMO_",
93 "AWS_",
94 "GITHUB_",
95 "GITLAB_",
96 "SSH_",
97 "GPG_",
98 "DOCKER_",
99 "VAULT_",
100 "NOMAD_",
101 "CONSUL_",
102 "HEROKU_",
103 "AZURE_",
104 "GCLOUD_",
105 "GOOGLE_CLOUD",
106 "GOOGLE_APPLICATION",
107 "SENTRY_DSN",
108 "DATADOG_",
109 "NEW_RELIC_",
110 "STRIPE_",
111 "TWILIO_",
112 "SENDGRID_",
113 "MAILGUN_",
114 "LDAP_",
115 "KRB5_",
116 "CUDA_", "LD_",
120 "DYLD_",
121];
122
123const SENSITIVE_ENV_SUFFIXES: &[&str] = &[
124 "_KEY",
125 "_TOKEN",
126 "_SECRET",
127 "_PASSWORD",
128 "_SECRETS",
129 "_CREDENTIAL",
130 "_CREDENTIALS",
131 "_CERT",
132 "_CERTIFICATE",
133 "_PRIVATE_KEY",
134 "_ACCESS_KEY",
135 "_SECRET_KEY",
136 "_SIGNING_KEY",
137 "_ENCRYPTION_KEY",
138 "_DECRYPTION_KEY",
139 "_API_KEY",
140 "_AUTH_TOKEN",
141 "_DSN",
142 "_URL",
143];
144
145const SAFE_ENV_VARS: &[&str] = &[
147 "HOME",
148 "USER",
149 "LOGNAME",
150 "PATH",
151 "TERM",
152 "LANG",
153 "LC_ALL",
154 "LC_CTYPE",
155 "TZ",
156 "PWD",
157 "OLDPWD",
158 "SHELL",
159 "EDITOR",
160 "VISUAL",
161 "DISPLAY",
162 "XAUTHORITY",
163 "WAYLAND_DISPLAY",
164 "DBUS_SESSION_BUS_ADDRESS",
165 "XDG_RUNTIME_DIR",
166 "XDG_SESSION_TYPE",
167 "XDG_CURRENT_DESKTOP",
168 "XDG_CONFIG_HOME",
169 "XDG_DATA_HOME",
170 "XDG_CACHE_HOME",
171 "COLORTERM",
172 "NO_COLOR",
173 "CLICOLOR",
174 "HOSTNAME",
175 "HOST",
176 "MACHTYPE",
177 "OSTYPE",
178 "SHLVL",
179 "LINENO",
180 "PPID",
181 "EUID",
182 "UID",
183 "RUNTIMO_ENABLE_NETWORK",
185 "RUNTIMO_ENABLE_INTERPRETERS",
186 "FOREIGN_KEY",
190 "PRIMARY_KEY",
191 "PUBLIC_KEY",
192 "BROWSER_URL",
193 "BASE_URL",
194];
195
196#[derive(Debug, Clone, Serialize, Deserialize)]
201#[allow(clippy::exhaustive_structs)] pub struct ShellExecArgs {
203 #[serde(alias = "command")]
205 pub cmd: String,
206 pub timeout_secs: Option<u64>,
208 pub cwd: Option<String>,
210 pub stdin: Option<String>,
212}
213
214fn command_matches(cmd_lower: &str, names: &[&str]) -> bool {
219 let first_token = cmd_lower.split_whitespace().next().unwrap_or("");
220 for part in cmd_lower.split(['|', '&', ';']) {
222 let t = part.trim();
223 if names
224 .iter()
225 .any(|n| t == *n || t.starts_with(&format!("{} ", n)))
226 {
227 return true;
228 }
229 }
230 names.contains(&first_token)
231}
232
233#[must_use]
254pub fn is_dangerous_command(cmd: &str) -> Option<&'static str> {
255 let cmd_lower = cmd.to_lowercase();
256 let detok_lower = detokenize_command(&cmd_lower);
259
260 if is_env_dumping_command(cmd) {
263 return Some("environment variable dumping command is blocked");
264 }
265
266 {
274 let has_func_def = cmd_lower.contains(":(){")
275 || cmd_lower.contains(":(){ ")
276 || cmd_lower.contains(":() {")
277 || detok_lower.contains(":(){")
278 || detok_lower.contains(":(){ ")
279 || detok_lower.contains(":() {");
280 let has_self_pipe = cmd_lower.contains(":|:&")
281 || cmd_lower.contains(":|: &")
282 || detok_lower.contains(":|:&")
283 || detok_lower.contains(":|: &");
284 if has_func_def && has_self_pipe {
285 return Some("fork bomb pattern blocked");
286 }
287 }
288
289 if cmd_lower.contains("<<") || detok_lower.contains("<<") {
295 return Some("heredoc/herestring (<<) is blocked — use inline commands");
296 }
297
298 if cmd_lower.contains("<(")
303 || cmd_lower.contains(">(")
304 || detok_lower.contains("<(")
305 || detok_lower.contains(">(")
306 {
307 return Some("process substitution (<( ) or >( )) is blocked");
308 }
309
310 if cmd.contains("$(")
316 || cmd.contains('`')
317 || detok_lower.contains("$(")
318 || detok_lower.contains('`')
319 {
320 return Some("command substitution ($( ) or backtick) is blocked");
321 }
322
323 if command_matches(&cmd_lower, &["rm"]) || command_matches(&detok_lower, &["rm"]) {
330 return Some("rm command blocked — use FileWrite/Undo capability");
331 }
332
333 let rm_no_preserve = "rm".to_string() + " --no-preserve-root";
335 if (cmd_lower.contains("rm") && cmd_lower.contains("--no-preserve-root"))
336 || (detok_lower.contains("rm") && detok_lower.contains("--no-preserve-root"))
337 || detok_lower.contains(&rm_no_preserve)
338 {
339 return Some("rm --no-preserve-root is blocked");
340 }
341
342 let rm_recursive_check = |s: &str| -> bool {
346 s.contains("rm")
347 && (s.contains("-rf")
348 || s.contains("-fr")
349 || s.contains("--recursive")
350 || s.contains(" -r ")
351 || s.contains(" -f "))
352 };
353
354 if rm_recursive_check(&cmd_lower) || rm_recursive_check(&detok_lower) {
355 return Some("recursive rm is blocked");
356 }
357
358 let mkfs_check = |s: &str| -> bool { s.contains("mkfs") || s.contains("mkswap") };
359 if mkfs_check(&cmd_lower) || mkfs_check(&detok_lower) {
360 return Some("filesystem creation commands are blocked");
361 }
362
363 let fdisk_check = |s: &str| -> bool { s.contains("fdisk") || s.contains("parted") };
364 if fdisk_check(&cmd_lower) || fdisk_check(&detok_lower) {
365 return Some("disk partitioning commands are blocked");
366 }
367
368 let dd_check = |s: &str| -> bool {
369 s.contains(" dd ")
370 || s.starts_with("dd ")
371 || s.ends_with(" dd")
372 || s.contains(" dd\t")
373 || s.starts_with("dd\t")
374 };
375 if dd_check(&cmd_lower) || dd_check(&detok_lower) {
376 return Some("dd (disk destroyer) is blocked");
377 }
378
379 if command_matches(&cmd_lower, &["shred"]) || command_matches(&detok_lower, &["shred"]) {
384 return Some("shred command blocked — use FileWrite/Undo capability");
385 }
386
387 let power_check = |s: &str| -> bool {
388 s.contains("shutdown") || s.contains("reboot") || s.contains("poweroff")
389 };
390 if power_check(&cmd_lower) || power_check(&detok_lower) {
391 return Some("system power commands are blocked");
392 }
393
394 if command_matches(&cmd_lower, &["chown", "chgrp"])
396 || command_matches(&detok_lower, &["chown", "chgrp"])
397 {
398 return Some("ownership change commands are blocked");
399 }
400
401 if command_matches(&cmd_lower, &["mount", "umount"])
403 || command_matches(&detok_lower, &["mount", "umount"])
404 {
405 return Some("mount/unmount commands are blocked");
406 }
407
408 if command_matches(&cmd_lower, &["iptables", "nft"])
410 || command_matches(&detok_lower, &["iptables", "nft"])
411 {
412 return Some("firewall manipulation commands are blocked");
413 }
414
415 if command_matches(&cmd_lower, &["kill", "killall", "pkill"])
421 || command_matches(&detok_lower, &["kill", "killall", "pkill"])
422 {
423 return Some("kill command blocked — use Kill capability");
424 }
425
426 let rm_system_check = |s: &str| -> bool {
428 s.contains("rm")
429 && (s.contains("-rf")
430 || s.contains("-fr")
431 || s.contains("--recursive")
432 || s.contains(" -r ")
433 || s.contains(" -f "))
434 && (s.contains(" / ")
435 || s.contains("/*")
436 || s.contains("/dev")
437 || s.contains("/boot")
438 || s.contains("/home")
439 || s.contains("/etc")
440 || s.contains("/usr")
441 || s.contains("/var")
442 || s.contains("/lib")
443 || s.contains("/opt")
444 || s.contains("/bin")
445 || s.contains("/sbin"))
446 };
447 if rm_system_check(&cmd_lower) || rm_system_check(&detok_lower) {
448 return Some("rm -rf / --recursive on system directories is blocked");
449 }
450
451 let rm_tilde_check = |s: &str| -> bool {
453 s.contains("rm")
454 && (s.contains("-rf")
455 || s.contains("-fr")
456 || s.contains("--recursive")
457 || s.contains(" -r ")
458 || s.contains(" -f "))
459 && s.contains('~')
460 };
461 if rm_tilde_check(&cmd_lower) || rm_tilde_check(&detok_lower) {
462 return Some("rm with shell expansions is blocked — use explicit paths");
463 }
464
465 if command_matches(&cmd_lower, &["chmod"]) || command_matches(&detok_lower, &["chmod"]) {
470 return Some("chmod command blocked — use FileWrite/Undo capability");
471 }
472
473 None
474}
475
476#[must_use]
483pub fn is_network_command(cmd: &str) -> bool {
484 let cmd_lower = cmd.to_lowercase();
485 command_matches(
486 &cmd_lower,
487 &[
488 "curl", "wget", "nc", "ncat", "netcat", "socat", "ssh", "scp", "telnet",
489 ],
490 )
491}
492
493#[must_use]
497pub fn network_enabled() -> bool {
498 std::env::var("RUNTIMO_ENABLE_NETWORK").as_deref() == Ok("1")
499}
500
501#[must_use]
511pub fn is_interpreter_command(cmd: &str) -> bool {
512 let cmd_lower = cmd.to_lowercase();
513 command_matches(
514 &cmd_lower,
515 &[
516 "python", "python3", "python2", "perl", "ruby", "node", "lua", "php", "tclsh", "wish",
517 "racket", "guile", "ghci", "runghc", "scala", "gawk", "nawk",
518 ],
519 )
520}
521
522#[must_use]
528pub fn interpreters_enabled() -> bool {
529 std::env::var("RUNTIMO_ENABLE_INTERPRETERS").as_deref() == Ok("1")
530}
531
532#[must_use]
545pub fn detokenize_command(cmd: &str) -> String {
546 const MAX_PASSES: usize = 16;
547
548 let mut current = cmd.to_string();
549 let mut previous;
550 let mut passes: usize = 0;
551
552 loop {
553 previous = current.clone();
554 current = detokenize_single_pass(&previous);
555 passes = passes.saturating_add(1);
556 if current == previous || passes >= MAX_PASSES {
557 break;
558 }
559 }
560 current
561}
562
563#[must_use]
567fn detokenize_single_pass(cmd: &str) -> String {
568 let mut result = String::with_capacity(cmd.len());
569 let mut chars = cmd.chars().peekable();
570
571 while let Some(&c) = chars.peek() {
572 match c {
573 '\\' => {
574 chars.next(); if let Some(next) = chars.next() {
577 if next != '\n' {
581 result.push(next);
582 }
583 }
584 }
586 '$' => {
587 chars.next(); if chars.peek() == Some(&'\'') {
591 chars.next(); while let Some(ch) = chars.next() {
594 if ch == '\'' {
595 break; }
597 if ch == '\\' {
598 match chars.next() {
600 Some('\\') => result.push('\\'),
601 Some('\'') => result.push('\''),
602 Some('"') => result.push('"'),
603 Some('?') => result.push('?'),
604 Some('a') => result.push('a'),
605 Some('b') => result.push('b'),
606 Some('f') => result.push('f'),
607 Some('n' | 'r' | 't' | 'v') => {
608 result.push(' ');
610 }
611 Some('e' | 'E') => result.push('e'),
612 Some('x') => {
614 let mut hex = String::new();
615 for _ in 0..2 {
616 if let Some(&h) = chars.peek() {
617 if h.is_ascii_hexdigit() {
618 hex.push(h);
619 chars.next();
620 } else {
621 break;
622 }
623 }
624 }
625 if let Ok(byte) = u8::from_str_radix(&hex, 16) {
626 if let Some(c) = char::from_u32(u32::from(byte)) {
627 result.push(c);
628 }
629 }
630 }
631 Some('u') => {
633 let mut hex = String::new();
634 for _ in 0..4 {
635 if let Some(&h) = chars.peek() {
636 if h.is_ascii_hexdigit() {
637 hex.push(h);
638 chars.next();
639 } else {
640 break;
641 }
642 }
643 }
644 if let Ok(cp) = u32::from_str_radix(&hex, 16) {
645 if let Some(c) = char::from_u32(cp) {
646 result.push(c);
647 }
648 }
649 }
650 Some('U') => {
652 let mut hex = String::new();
653 for _ in 0..8 {
654 if let Some(&h) = chars.peek() {
655 if h.is_ascii_hexdigit() {
656 hex.push(h);
657 chars.next();
658 } else {
659 break;
660 }
661 }
662 }
663 if let Ok(cp) = u32::from_str_radix(&hex, 16) {
664 if let Some(c) = char::from_u32(cp) {
665 result.push(c);
666 }
667 }
668 }
669 Some(d) if ('0'..='7').contains(&d) => {
671 let mut octal = String::from(d);
672 for _ in 0..2 {
673 if let Some(&od) = chars.peek() {
674 if ('0'..='7').contains(&od) {
675 octal.push(od);
676 chars.next();
677 } else {
678 break;
679 }
680 }
681 }
682 if let Ok(byte) = u8::from_str_radix(&octal, 8) {
683 if let Some(c) = char::from_u32(u32::from(byte)) {
684 result.push(c);
685 }
686 }
687 }
688 Some(ctrl) => result.push(ctrl),
690 None => {}
692 }
693 } else {
694 result.push(ch);
695 }
696 }
697 } else {
698 result.push('$');
700 }
701 }
702 '\'' => {
703 chars.next(); for ch in chars.by_ref() {
706 if ch == '\'' {
707 break; }
709 result.push(ch);
710 }
711 }
712 '"' => {
713 chars.next(); while let Some(ch) = chars.next() {
716 if ch == '"' {
717 break; }
719 if ch == '\\' {
720 if let Some(&next_ch) = chars.peek() {
721 match next_ch {
722 '"' | '$' | '`' | '\\' | '\n' => {
723 chars.next(); result.push(next_ch);
725 continue;
726 }
727 _ => {
728 result.push('\\');
730 continue;
732 }
733 }
734 }
735 result.push('\\');
737 break;
738 }
739 result.push(ch);
740 }
741 }
742 _ => {
743 result.push(c);
744 chars.next(); }
746 }
747 }
748 result
749}
750
751#[must_use]
760fn is_sensitive_env_var(key: &str) -> bool {
761 if SAFE_ENV_VARS.contains(&key) {
763 return false;
764 }
765 let key_upper = key.to_uppercase();
766 if SENSITIVE_ENV_PREFIXES
768 .iter()
769 .any(|prefix| key_upper.starts_with(prefix))
770 {
771 return true;
772 }
773 SENSITIVE_ENV_SUFFIXES
775 .iter()
776 .any(|suffix| key_upper.ends_with(suffix))
777}
778
779#[must_use]
786fn sanitized_env() -> Vec<(String, String)> {
787 std::env::vars()
788 .filter(|(key, _)| !is_sensitive_env_var(key))
789 .collect()
790}
791
792#[must_use]
802pub fn is_env_dumping_command(cmd: &str) -> bool {
803 let cmd_lower = cmd.to_lowercase().trim().to_string();
804 let detok_lower = detokenize_command(&cmd_lower);
806
807 let env_dumpers: &[&str] = &[
808 "env", "printenv", "set", "export", "declare", "typeset", "compgen",
809 ];
810
811 for dumper in env_dumpers {
812 if command_matches(&cmd_lower, &[dumper]) {
814 return true;
815 }
816 if command_matches(&detok_lower, &[dumper]) {
818 return true;
819 }
820 }
821 false
822}
823
824#[must_use]
835fn is_path_within_allowed(path_str: &str, allowed: &[String]) -> bool {
836 allowed
837 .iter()
838 .filter(|prefix| !prefix.is_empty())
839 .any(|prefix| path_str == prefix || path_str.starts_with(&format!("{}/", prefix)))
840}
841
842#[must_use]
852fn expand_shell_vars(token: &str) -> String {
853 let mut result = String::with_capacity(token.len());
854 let mut chars = token.chars().peekable();
855 let mut expanded_any = false;
856
857 while let Some(&c) = chars.peek() {
858 if c == '$' {
859 chars.next(); if chars.peek() == Some(&'{') {
862 chars.next(); let mut var_name = String::new();
864 while let Some(&ch) = chars.peek() {
865 if ch == '}' {
866 chars.next(); break;
868 }
869 var_name.push(ch);
870 chars.next();
871 }
872 if let Ok(value) = std::env::var(&var_name) {
874 result.push_str(&value);
875 expanded_any = true;
876 } else {
877 result.push('$');
879 result.push('{');
880 result.push_str(&var_name);
881 result.push('}');
882 }
883 } else {
884 let mut var_name = String::new();
886 while let Some(&ch) = chars.peek() {
887 if ch.is_alphanumeric() || ch == '_' {
888 var_name.push(ch);
889 chars.next();
890 } else {
891 break;
892 }
893 }
894 if var_name.is_empty() {
895 result.push('$');
897 } else if let Ok(value) = std::env::var(&var_name) {
898 result.push_str(&value);
899 expanded_any = true;
900 } else {
901 result.push('$');
903 result.push_str(&var_name);
904 }
905 }
906 } else {
907 result.push(c);
908 chars.next();
909 }
910 }
911
912 if expanded_any {
913 result
914 } else {
915 token.to_string()
916 }
917}
918
919#[must_use]
942fn check_command_paths(cmd: &str) -> Option<String> {
943 let allowed = RuntimoConfig::get_allowed_prefixes();
944 let detok = detokenize_command(cmd);
945
946 for token in detok.split_whitespace() {
948 let path = token.trim_matches(|c: char| c == '"' || c == '\'' || c == '`' || c == ',');
950
951 if path.is_empty() || path.len() < 2 {
953 continue;
954 }
955
956 if path.starts_with('-') {
958 continue;
959 }
960
961 let path_without_redirect = path
966 .trim_start_matches("&>>")
967 .trim_start_matches("2>>")
968 .trim_start_matches("1>>")
969 .trim_start_matches("&>")
970 .trim_start_matches("2>")
971 .trim_start_matches("1>")
972 .trim_start_matches(">>")
973 .trim_start_matches('>')
974 .trim_start_matches('<');
975
976 let path = if path_without_redirect != path
978 && (path_without_redirect.starts_with('/') || path_without_redirect.starts_with("~/"))
979 {
980 path_without_redirect
981 } else {
982 path
983 };
984
985 if path.contains('=') && !path.starts_with('/') && !path.starts_with("~/") {
987 continue;
988 }
989
990 let resolved = if path.contains('$') {
995 let expanded = expand_shell_vars(path);
996 if expanded == path {
997 continue;
1000 }
1001 if expanded.starts_with('/') {
1003 let clean = expanded.trim_end_matches(|c: char| {
1005 c == ';' || c == '|' || c == '&' || c == '>' || c == '<'
1006 });
1007 clean.to_string()
1008 } else {
1009 continue;
1011 }
1012 } else if path.starts_with("~/") {
1013 match std::env::var("HOME") {
1014 Ok(home) => {
1015 let mut home_path = home.trim_end_matches('/').to_string();
1016 home_path.push_str(&path[1..]); home_path
1018 }
1019 Err(_) => continue, }
1021 } else if path.starts_with('/') {
1022 let clean_end = path.trim_end_matches(|c: char| {
1025 c == ';' || c == '|' || c == '&' || c == '>' || c == '<'
1026 });
1027 clean_end.to_string()
1028 } else if path.starts_with('.') {
1029 let Ok(cwd) = std::env::current_dir() else {
1034 continue;
1035 };
1036 let joined = cwd.join(path);
1037 let mut components: Vec<&str> = Vec::new();
1039 for component in joined.components() {
1040 match component {
1041 std::path::Component::ParentDir => {
1042 components.pop(); }
1044 std::path::Component::Normal(os_str) => {
1045 if let Some(s) = os_str.to_str() {
1046 components.push(s);
1047 }
1048 }
1049 std::path::Component::RootDir => {
1050 components.clear();
1052 }
1053 _ => {}
1055 }
1056 }
1057 let normalized = format!("/{}", components.join("/"));
1058 if normalized.contains("/../") || normalized.contains("/..") || normalized == "/.." {
1060 return Some(format!(
1061 "ShellExec blocked: path traversal not allowed: {}",
1062 path
1063 ));
1064 }
1065 normalized
1066 } else {
1067 continue;
1068 };
1069
1070 if resolved == "/" {
1072 continue;
1073 }
1074
1075 if resolved.contains("/../") || resolved.contains("/..") || resolved == ".." {
1078 return Some(format!(
1079 "ShellExec blocked: path traversal not allowed: {}",
1080 path
1081 ));
1082 }
1083
1084 if resolved.starts_with("/dev/") {
1086 continue;
1087 }
1088
1089 if resolved.starts_with("/proc/") || resolved.starts_with("/sys/") {
1091 continue;
1092 }
1093
1094 if !is_path_within_allowed(&resolved, &allowed) {
1095 let display_path = if path.starts_with("~/") {
1097 path.to_string()
1098 } else {
1099 resolved
1100 };
1101 return Some(format!(
1102 "ShellExec blocked: path is outside allowed directories: {}",
1103 display_path
1104 ));
1105 }
1106 }
1107
1108 None
1109}
1110
1111#[allow(clippy::arithmetic_side_effects)] fn wait_with_timeout(child: &mut Child, pgid: u32, timeout_secs: u64) -> WaitResult {
1113 let start = Instant::now();
1114 let timeout = Duration::from_secs(timeout_secs);
1115 let child_pid = child.id();
1116 let stdout_thread = child.stdout.take().map(|stdout| {
1117 thread::spawn(move || {
1118 let mut data = Vec::new();
1119 let _ = stdout.take(MAX_OUTPUT_BYTES as u64).read_to_end(&mut data);
1120 data
1121 })
1122 });
1123 let stderr_thread = child.stderr.take().map(|stderr| {
1124 thread::spawn(move || {
1125 let mut data = Vec::new();
1126 let _ = stderr.take(MAX_OUTPUT_BYTES as u64).read_to_end(&mut data);
1127 data
1128 })
1129 });
1130 let mut last_descendants: Vec<u32>;
1131 loop {
1132 if start.elapsed() > timeout {
1133 #[allow(clippy::cast_possible_wrap)]
1136 unsafe {
1137 let _ = libc::kill(-(pgid as libc::pid_t), libc::SIGKILL);
1138 }
1139 let killed_descendants = get_all_descendants(child_pid);
1140 let _ = child.wait();
1141 let _ = stdout_thread.map(|h| h.join().unwrap_or_default());
1142 let _ = stderr_thread.map(|h| h.join().unwrap_or_default());
1143 return Err(Error::ExecutionFailed(format!(
1144 "command timed out after {}s (killed {} descendants)",
1145 timeout_secs,
1146 killed_descendants.len()
1147 )));
1148 }
1149 last_descendants = get_all_descendants(child_pid);
1150 match child.try_wait() {
1151 Ok(Some(status)) => {
1152 let stdout_data = stdout_thread
1153 .map(|h| h.join().unwrap_or_default())
1154 .unwrap_or_default();
1155 let stderr_data = stderr_thread
1156 .map(|h| h.join().unwrap_or_default())
1157 .unwrap_or_default();
1158 return Ok((status, stdout_data, stderr_data, last_descendants));
1159 }
1160 Ok(None) => std::thread::sleep(Duration::from_millis(50)),
1161 Err(e) => return Err(Error::ExecutionFailed(format!("error waiting: {}", e))),
1162 }
1163 }
1164}
1165
1166fn get_direct_children(pid: u32) -> Vec<u32> {
1167 let children_path = format!("/proc/{}/children", pid);
1168 if let Ok(content) = fs::read_to_string(&children_path) {
1169 content
1170 .split_whitespace()
1171 .filter_map(|s| s.parse::<u32>().ok())
1172 .collect()
1173 } else {
1174 Vec::new()
1175 }
1176}
1177
1178fn get_all_descendants(pid: u32) -> Vec<u32> {
1179 let mut descendants = Vec::new();
1180 let mut stack = vec![pid];
1181 let mut visited = std::collections::HashSet::new();
1182 while let Some(current) = stack.pop() {
1183 if visited.contains(¤t) {
1184 continue;
1185 }
1186 visited.insert(current);
1187 let children = get_direct_children(current);
1188 if children.is_empty() {
1189 if let Ok(output) = std::process::Command::new("pgrep")
1190 .arg("-P")
1191 .arg(current.to_string())
1192 .output()
1193 {
1194 if output.status.success() {
1195 let pgrep_lines = String::from_utf8_lossy(&output.stdout).to_string();
1196 let pgrep_children = pgrep_lines
1197 .lines()
1198 .filter_map(|s| s.trim().parse::<u32>().ok());
1199 for child in pgrep_children {
1200 if !visited.contains(&child) {
1201 descendants.push(child);
1202 stack.push(child);
1203 }
1204 }
1205 continue;
1206 }
1207 }
1208 }
1209 for child in children {
1210 if !visited.contains(&child) {
1211 descendants.push(child);
1212 stack.push(child);
1213 }
1214 }
1215 }
1216 descendants
1217}
1218
1219#[allow(clippy::exhaustive_structs)]
1225pub struct ShellExec;
1226
1227impl TypedCapability for ShellExec {
1228 type Args = ShellExecArgs;
1229
1230 fn name(&self) -> &'static str {
1231 "ShellExec"
1232 }
1233 fn description(&self) -> &'static str {
1234 "execute shell command via sh -c with timeout, audit trail, detokenized blocklist, path restrictions, env sanitization, and PID tracking. blocks: rm, shred, mkfs, fdisk, dd, shutdown, chown, chmod, kill, mount, iptables, interpreters (opt-in), network tools (opt-in), fork bombs, env dumpers."
1235 }
1236 fn schema(&self) -> Value {
1237 serde_json::json!({
1238 "type": "object",
1239 "properties": {
1240 "cmd": { "type": "string", "description": "Command to execute via sh -c" },
1241 "timeout_secs": { "type": "integer", "minimum": 1, "maximum": 300 },
1242 "cwd": { "type": "string" },
1243 "stdin": { "type": "string" }
1244 },
1245 "required": ["cmd"]
1246 })
1247 }
1248 fn execute(
1249 &self,
1250 args: ShellExecArgs,
1251 ctx: &Context,
1252 ) -> std::result::Result<Output, CapabilityError> {
1253 let timeout = args.timeout_secs.unwrap_or(DEFAULT_TIMEOUT_SECS);
1255
1256 if let Some(reason) = is_dangerous_command(&args.cmd) {
1259 return Err(CapabilityError::PermissionDenied(format!(
1260 "dangerous command blocked: {}",
1261 reason
1262 )));
1263 }
1264
1265 if !network_enabled() && is_network_command(&args.cmd) {
1268 return Err(CapabilityError::PermissionDenied(
1269 "network commands blocked — set RUNTIMO_ENABLE_NETWORK=1 to enable".into(),
1270 ));
1271 }
1272
1273 if !interpreters_enabled() && is_interpreter_command(&args.cmd) {
1277 return Err(CapabilityError::PermissionDenied(
1278 "interpreter commands blocked — set RUNTIMO_ENABLE_INTERPRETERS=1 to enable".into(),
1279 ));
1280 }
1281
1282 if let Some(reason) = check_command_paths(&args.cmd) {
1284 return Err(CapabilityError::PermissionDenied(reason));
1285 }
1286
1287 if ctx.dry_run {
1290 let mut out = Output::ok("DRY RUN".into());
1291 out.data = Some(serde_json::json!({ "cmd": &args.cmd, "dry_run": true }));
1292 return Ok(out);
1293 }
1294
1295 let mut cmd = Command::new("sh");
1296 cmd.env("PATH", "/usr/local/bin:/usr/bin:/bin");
1301
1302 let safe_env = sanitized_env();
1306 for (key, value) in &safe_env {
1307 if key == "PATH" {
1309 continue;
1310 }
1311 cmd.env(key, value);
1312 }
1313
1314 cmd.arg("-c").arg(&args.cmd);
1315 if let Some(cwd) = &args.cwd {
1316 let path_ctx = PathContext {
1317 require_exists: true,
1318 require_file: false,
1319 ..Default::default()
1320 };
1321 let cwd_path = validate_path(cwd, &path_ctx)
1322 .map_err(|e| CapabilityError::PermissionDenied(format!("invalid cwd: {}", e)))?;
1323 cmd.current_dir(cwd_path);
1324 }
1325 let mut child = cmd
1326 .process_group(0)
1327 .stdout(std::process::Stdio::piped())
1328 .stderr(std::process::Stdio::piped())
1329 .stdin(if args.stdin.is_some() {
1330 std::process::Stdio::piped()
1331 } else {
1332 std::process::Stdio::null()
1333 })
1334 .spawn()
1335 .map_err(|e| {
1336 CapabilityError::Io(std::io::Error::other(format!("failed to spawn: {}", e)))
1337 })?;
1338 let child_pid = child.id();
1339 let pgid = child_pid;
1340 if let Some(ref stdin_content) = args.stdin {
1341 if stdin_content.len() > MAX_STDIN_BYTES {
1342 return Err(CapabilityError::InvalidArgs("stdin too large".into()));
1343 }
1344 if let Some(mut stdin_pipe) = child.stdin.take() {
1345 let _ = stdin_pipe.write_all(stdin_content.as_bytes());
1346 }
1347 }
1348 let (exit_status, stdout, stderr, _descendants) =
1349 wait_with_timeout(&mut child, pgid, timeout)
1350 .map_err(|e| CapabilityError::Internal(e.to_string()))?;
1351 let stdout_str = String::from_utf8_lossy(&stdout).to_string();
1352 let stderr_str = String::from_utf8_lossy(&stderr).to_string();
1353 let success = exit_status.success();
1354
1355 let mut out = if success {
1356 Output::ok("completed".into())
1357 } else {
1358 Output::error(
1359 format!("exit code {}", exit_status.code().unwrap_or(-1)),
1360 format!("exit code {}", exit_status.code().unwrap_or(-1)),
1361 )
1362 };
1363 out.data = Some(
1364 serde_json::json!({ "cmd": &args.cmd, "stdout": stdout_str, "stderr": stderr_str, "exit_code": exit_status.code().unwrap_or(-1), "pid": child_pid, "timeout_secs": timeout, "timed_out": exit_status.code().is_none(), "truncated": stdout.len() >= MAX_OUTPUT_BYTES || stderr.len() >= MAX_OUTPUT_BYTES }),
1365 );
1366 Ok(out)
1367 }
1368}
1369
1370#[cfg(test)]
1371mod tests {
1372 use super::*;
1373 use crate::capability::Capability;
1374 use std::time::Instant;
1375 #[test]
1376 fn executes_uptime() {
1377 let r = Capability::execute(
1378 &ShellExec,
1379 &serde_json::json!({"cmd": "uptime"}),
1380 &Context {
1381 dry_run: false,
1382 job_id: "test".into(),
1383 working_dir: std::env::temp_dir(),
1384 },
1385 )
1386 .unwrap();
1387 assert_eq!(r.status, "ok");
1388 }
1389 #[test]
1390 fn pipes_work() {
1391 let r = Capability::execute(
1392 &ShellExec,
1393 &serde_json::json!({"cmd": "echo hi | cat"}),
1394 &Context {
1395 dry_run: false,
1396 job_id: "test".into(),
1397 working_dir: std::env::temp_dir(),
1398 },
1399 )
1400 .unwrap();
1401 assert_eq!(r.status, "ok");
1402 assert!(r.data.as_ref().unwrap()["stdout"]
1403 .as_str()
1404 .unwrap()
1405 .contains("hi"));
1406 }
1407 #[test]
1408 fn chaining_works() {
1409 let r = Capability::execute(
1410 &ShellExec,
1411 &serde_json::json!({"cmd": "echo a && echo b"}),
1412 &Context {
1413 dry_run: false,
1414 job_id: "test".into(),
1415 working_dir: std::env::temp_dir(),
1416 },
1417 )
1418 .unwrap();
1419 assert_eq!(r.status, "ok");
1420 }
1421 #[test]
1422 fn blocks_dangerous() {
1423 assert!(Capability::execute(
1424 &ShellExec,
1425 &serde_json::json!({"cmd": "mkfs"}),
1426 &Context {
1427 dry_run: false,
1428 job_id: "test".into(),
1429 working_dir: std::env::temp_dir(),
1430 }
1431 )
1432 .is_err());
1433 }
1434 #[test]
1435 fn blocks_recursive_flag() {
1436 assert!(Capability::execute(
1438 &ShellExec,
1439 &serde_json::json!({"cmd": "rm --recursive /home"}),
1440 &Context {
1441 dry_run: false,
1442 job_id: "test".into(),
1443 working_dir: std::env::temp_dir(),
1444 }
1445 )
1446 .is_err());
1447 }
1448 #[test]
1449 fn blocks_rm_rf_root() {
1450 assert!(Capability::execute(
1452 &ShellExec,
1453 &serde_json::json!({"cmd": "rm -rf /"}),
1454 &Context {
1455 dry_run: false,
1456 job_id: "test".into(),
1457 working_dir: std::env::temp_dir(),
1458 }
1459 )
1460 .is_err());
1461 }
1462 #[test]
1463 fn blocks_rm_no_preserve_root() {
1464 assert!(Capability::execute(
1465 &ShellExec,
1466 &serde_json::json!({"cmd": "rm --no-preserve-root -rf /"}),
1467 &Context {
1468 dry_run: false,
1469 job_id: "test".into(),
1470 working_dir: std::env::temp_dir(),
1471 }
1472 )
1473 .is_err());
1474 }
1475 #[test]
1476 fn blocks_ownership_commands() {
1477 for cmd in &["chown root /tmp/x", "chgrp staff /tmp/x"] {
1478 assert!(
1479 Capability::execute(
1480 &ShellExec,
1481 &serde_json::json!({"cmd": cmd}),
1482 &Context {
1483 dry_run: false,
1484 job_id: "test".into(),
1485 working_dir: std::env::temp_dir(),
1486 }
1487 )
1488 .is_err(),
1489 "should block: {}",
1490 cmd
1491 );
1492 }
1493 }
1494 #[test]
1495 fn blocks_mount_commands() {
1496 for cmd in &["mount /dev/sda1 /mnt", "umount /mnt"] {
1497 assert!(
1498 Capability::execute(
1499 &ShellExec,
1500 &serde_json::json!({"cmd": cmd}),
1501 &Context {
1502 dry_run: false,
1503 job_id: "test".into(),
1504 working_dir: std::env::temp_dir(),
1505 }
1506 )
1507 .is_err(),
1508 "should block: {}",
1509 cmd
1510 );
1511 }
1512 }
1513 #[test]
1514 fn blocks_firewall_commands() {
1515 for cmd in &["iptables -L", "nft list ruleset"] {
1516 assert!(
1517 Capability::execute(
1518 &ShellExec,
1519 &serde_json::json!({"cmd": cmd}),
1520 &Context {
1521 dry_run: false,
1522 job_id: "test".into(),
1523 working_dir: std::env::temp_dir(),
1524 }
1525 )
1526 .is_err(),
1527 "should block: {}",
1528 cmd
1529 );
1530 }
1531 }
1532 #[test]
1533 fn blocks_network_commands_by_default() {
1534 std::env::remove_var("RUNTIMO_ENABLE_NETWORK");
1536 for cmd in &[
1537 "curl http://example.com",
1538 "wget http://example.com",
1539 "nc example.com 80",
1540 ] {
1541 assert!(
1542 Capability::execute(
1543 &ShellExec,
1544 &serde_json::json!({"cmd": cmd}),
1545 &Context {
1546 dry_run: false,
1547 job_id: "test".into(),
1548 working_dir: std::env::temp_dir(),
1549 }
1550 )
1551 .is_err(),
1552 "should block network cmd: {}",
1553 cmd
1554 );
1555 }
1556 }
1557 #[test]
1558 fn allows_network_commands_when_enabled() {
1559 std::env::set_var("RUNTIMO_ENABLE_NETWORK", "1");
1560 let r = Capability::execute(
1562 &ShellExec,
1563 &serde_json::json!({"cmd": "curl --version"}),
1564 &Context {
1565 dry_run: false,
1566 job_id: "test".into(),
1567 working_dir: std::env::temp_dir(),
1568 },
1569 );
1570 std::env::remove_var("RUNTIMO_ENABLE_NETWORK");
1571 match r {
1573 Ok(o) => assert_eq!(o.status, "ok", "curl --version should succeed when enabled"),
1574 Err(e) => {
1575 let msg = e.to_string();
1576 assert!(
1577 !msg.contains("network commands blocked"),
1578 "should NOT block network when RUNTIMO_ENABLE_NETWORK=1, got: {}",
1579 msg
1580 );
1581 }
1582 }
1583 }
1584 #[test]
1585 fn enforces_timeout() {
1586 let s = Instant::now();
1587 assert!(Capability::execute(
1588 &ShellExec,
1589 &serde_json::json!({"cmd": "sleep 5", "timeout_secs": 1}),
1590 &Context {
1591 dry_run: false,
1592 job_id: "test".into(),
1593 working_dir: std::env::temp_dir(),
1594 }
1595 )
1596 .is_err());
1597 assert!(s.elapsed().as_secs() < 3);
1598 }
1599
1600 #[test]
1605 fn detokenize_ansi_c_tab_expansion() {
1606 let detok = detokenize_command("$'rm\\t-rf\\t/'");
1608 assert!(detok.contains("rm"));
1610 assert!(detok.contains("-rf"));
1611 assert!(detok.contains('/'));
1612 }
1613
1614 #[test]
1615 fn detokenize_ansi_c_plain_content() {
1616 let detok = detokenize_command("$'rm -rf /'");
1618 assert!(detok.contains("rm -rf /"));
1619 }
1620
1621 #[test]
1622 fn detokenize_ansi_c_hex_escape() {
1623 let detok = detokenize_command("$'\\x72\\x6d'");
1625 assert!(
1626 detok.contains("rm"),
1627 "Hex-encoded 'rm' should decode, got: {:?}",
1628 detok
1629 );
1630 }
1631
1632 #[test]
1633 fn detokenize_ansi_c_unicode_escape() {
1634 let detok = detokenize_command("$'\\u0072\\u006d'");
1636 assert!(
1637 detok.contains("rm"),
1638 "Unicode-encoded 'rm' should decode, got: {:?}",
1639 detok
1640 );
1641 }
1642
1643 #[test]
1644 fn detokenize_ansi_c_octal_escape() {
1645 let detok = detokenize_command("$'\\162\\155'");
1647 assert!(
1648 detok.contains("rm"),
1649 "Octal-encoded 'rm' should decode, got: {:?}",
1650 detok
1651 );
1652 }
1653
1654 #[test]
1655 fn detokenize_ansi_c_newline_expansion() {
1656 let detok = detokenize_command("$'rm\\n-rf\\n/'");
1658 assert!(detok.contains("rm"));
1659 assert!(detok.contains("-rf"));
1660 }
1661
1662 #[test]
1663 fn detokenize_ansi_c_combined_escapes() {
1664 let detok = detokenize_command("$'m\\x6bfs'");
1666 assert!(
1667 detok.contains("mkfs"),
1668 "Should decode to mkfs, got: {:?}",
1669 detok
1670 );
1671 }
1672
1673 #[test]
1674 fn blocks_rm_via_ansi_c_bypass() {
1675 let err = Capability::execute(
1677 &ShellExec,
1678 &serde_json::json!({"cmd": "$'rm\\t-rf\\t/'"}),
1679 &Context {
1680 dry_run: false,
1681 job_id: "test".into(),
1682 working_dir: std::env::temp_dir(),
1683 },
1684 )
1685 .unwrap_err();
1686 let msg = format!("{}", err);
1687 assert!(
1688 msg.contains("recursive rm") || msg.contains("dangerous command blocked"),
1689 "Should block ANSI-C quoted rm, got: {}",
1690 msg
1691 );
1692 }
1693
1694 #[test]
1695 fn blocks_rm_via_ansi_c_hex_bypass() {
1696 let err = Capability::execute(
1698 &ShellExec,
1699 &serde_json::json!({"cmd": "$'\\x72\\x6d' -rf /"}),
1700 &Context {
1701 dry_run: false,
1702 job_id: "test".into(),
1703 working_dir: std::env::temp_dir(),
1704 },
1705 )
1706 .unwrap_err();
1707 assert!(
1708 format!("{}", err).contains("recursive rm")
1709 || format!("{}", err).contains("rm command blocked"),
1710 "Should block hex-encoded rm bypass"
1711 );
1712 }
1713
1714 #[test]
1717 fn blocks_chmod_via_quote_bypass() {
1718 let err = Capability::execute(
1720 &ShellExec,
1721 &serde_json::json!({"cmd": "c\"hmod\" 777 /"}),
1722 &Context {
1723 dry_run: false,
1724 job_id: "test".into(),
1725 working_dir: std::env::temp_dir(),
1726 },
1727 )
1728 .unwrap_err();
1729 assert!(
1730 format!("{}", err).contains("chmod"),
1731 "Should block c\"hmod\" 777 / (quoted chmod bypass)"
1732 );
1733 }
1734
1735 #[test]
1740 fn detokenize_backslash_newline_continuation() {
1741 let cmd_with_newline = "r\\\nm -rf /";
1743 let detok = detokenize_command(cmd_with_newline);
1744 assert!(
1745 detok.contains("rm"),
1746 "Backslash-newline should be stripped, got: {:?}",
1747 detok
1748 );
1749 assert!(
1750 detok.contains("rm -rf /"),
1751 "Should rejoin tokens across newline, got: {:?}",
1752 detok
1753 );
1754 }
1755
1756 #[test]
1757 fn blocks_rm_via_backslash_newline_bypass() {
1758 let err = Capability::execute(
1760 &ShellExec,
1761 &serde_json::json!({"cmd": "r\\\nm -rf /"}),
1762 &Context {
1763 dry_run: false,
1764 job_id: "test".into(),
1765 working_dir: std::env::temp_dir(),
1766 },
1767 )
1768 .unwrap_err();
1769 assert!(
1770 format!("{}", err).contains("recursive rm")
1771 || format!("{}", err).contains("rm command blocked"),
1772 "Should block backslash-newline rm bypass"
1773 );
1774 }
1775
1776 #[test]
1777 fn detokenize_multi_pass_stability() {
1778 let detok = detokenize_command("\"'rm'\" -rf /");
1780 assert!(
1782 detok.contains("rm"),
1783 "Multi-pass should converge, got: {:?}",
1784 detok
1785 );
1786 }
1787
1788 #[test]
1789 fn detokenize_roundtrip_idempotent() {
1790 let cmd = "r\"m\" -rf /";
1792 let detok1 = detokenize_command(cmd);
1793 let detok2 = detokenize_command(&detok1);
1794 assert_eq!(detok1, detok2, "Detokenization should be idempotent");
1795 }
1796
1797 #[test]
1800 fn blocks_heredoc() {
1801 let err = Capability::execute(
1803 &ShellExec,
1804 &serde_json::json!({"cmd": "cat <<EOF\nevil\nEOF"}),
1805 &Context {
1806 dry_run: false,
1807 job_id: "test".into(),
1808 working_dir: std::env::temp_dir(),
1809 },
1810 )
1811 .unwrap_err();
1812 let msg = format!("{}", err);
1813 assert!(
1814 msg.contains("heredoc"),
1815 "Should block heredoc (<<), got: {}",
1816 msg
1817 );
1818 }
1819
1820 #[test]
1821 fn blocks_herestring() {
1822 let err = Capability::execute(
1824 &ShellExec,
1825 &serde_json::json!({"cmd": "cat <<<\"hello\""}),
1826 &Context {
1827 dry_run: false,
1828 job_id: "test".into(),
1829 working_dir: std::env::temp_dir(),
1830 },
1831 )
1832 .unwrap_err();
1833 assert!(
1834 format!("{}", err).contains("heredoc"),
1835 "Should block herestring (<<<)"
1836 );
1837 }
1838
1839 #[test]
1840 fn blocks_heredoc_via_quote_bypass() {
1841 let err = Capability::execute(
1843 &ShellExec,
1844 &serde_json::json!({"cmd": "<\"<\"EOF\nevil\nEOF"}),
1845 &Context {
1846 dry_run: false,
1847 job_id: "test".into(),
1848 working_dir: std::env::temp_dir(),
1849 },
1850 )
1851 .unwrap_err();
1852 assert!(
1853 format!("{}", err).contains("heredoc"),
1854 "Should block quoted heredoc bypass"
1855 );
1856 }
1857
1858 #[test]
1861 fn blocks_process_substitution_input() {
1862 let err = Capability::execute(
1864 &ShellExec,
1865 &serde_json::json!({"cmd": "diff <(curl http://evil) <(ls)"}),
1866 &Context {
1867 dry_run: false,
1868 job_id: "test".into(),
1869 working_dir: std::env::temp_dir(),
1870 },
1871 )
1872 .unwrap_err();
1873 assert!(
1874 format!("{}", err).contains("process substitution"),
1875 "Should block <( ) process substitution"
1876 );
1877 }
1878
1879 #[test]
1880 fn blocks_process_substitution_output() {
1881 let err = Capability::execute(
1883 &ShellExec,
1884 &serde_json::json!({"cmd": "echo evil > >(tee /etc/cron.d/backdoor)"}),
1885 &Context {
1886 dry_run: false,
1887 job_id: "test".into(),
1888 working_dir: std::env::temp_dir(),
1889 },
1890 )
1891 .unwrap_err();
1892 assert!(
1893 format!("{}", err).contains("process substitution"),
1894 "Should block >( ) process substitution"
1895 );
1896 }
1897
1898 #[test]
1901 fn blocks_command_substitution_dollar_paren() {
1902 let err = Capability::execute(
1904 &ShellExec,
1905 &serde_json::json!({"cmd": "$(echo rm) -rf /"}),
1906 &Context {
1907 dry_run: false,
1908 job_id: "test".into(),
1909 working_dir: std::env::temp_dir(),
1910 },
1911 )
1912 .unwrap_err();
1913 assert!(
1914 format!("{}", err).contains("command substitution"),
1915 "Should block $( ) command substitution"
1916 );
1917 }
1918
1919 #[test]
1920 fn blocks_command_substitution_backtick() {
1921 let err = Capability::execute(
1923 &ShellExec,
1924 &serde_json::json!({"cmd": "`echo rm` -rf /"}),
1925 &Context {
1926 dry_run: false,
1927 job_id: "test".into(),
1928 working_dir: std::env::temp_dir(),
1929 },
1930 )
1931 .unwrap_err();
1932 assert!(
1933 format!("{}", err).contains("command substitution"),
1934 "Should block backtick command substitution"
1935 );
1936 }
1937
1938 #[test]
1939 fn blocks_command_substitution_in_double_quotes() {
1940 let err = Capability::execute(
1942 &ShellExec,
1943 &serde_json::json!({"cmd": "\"$(echo rm)\" -rf /"}),
1944 &Context {
1945 dry_run: false,
1946 job_id: "test".into(),
1947 working_dir: std::env::temp_dir(),
1948 },
1949 )
1950 .unwrap_err();
1951 assert!(
1952 format!("{}", err).contains("command substitution"),
1953 "Should block $( ) even inside double quotes"
1954 );
1955 }
1956
1957 #[test]
1960 fn detokenize_strips_double_quotes() {
1961 assert_eq!(detokenize_command("r\"m\" -rf /"), "rm -rf /");
1963 }
1964
1965 #[test]
1966 fn detokenize_strips_single_quotes() {
1967 assert_eq!(detokenize_command("'r''m' -rf /"), "rm -rf /");
1969 }
1970
1971 #[test]
1972 fn detokenize_strips_backslash() {
1973 assert_eq!(detokenize_command("r\\m -rf /"), "rm -rf /");
1975 }
1976
1977 #[test]
1978 fn detokenize_mixed_quotes() {
1979 assert_eq!(detokenize_command("r\"m\" -r\"f\""), "rm -rf");
1981 }
1982
1983 #[test]
1984 fn detokenize_preserves_non_quoted() {
1985 assert_eq!(detokenize_command("echo hello"), "echo hello");
1987 assert_eq!(detokenize_command("ls -la /tmp"), "ls -la /tmp");
1988 }
1989
1990 #[test]
1991 fn blocks_rm_via_double_quote_bypass() {
1992 let err = Capability::execute(
1994 &ShellExec,
1995 &serde_json::json!({"cmd": "r\"m\" -rf /"}),
1996 &Context {
1997 dry_run: false,
1998 job_id: "test".into(),
1999 working_dir: std::env::temp_dir(),
2000 },
2001 )
2002 .unwrap_err();
2003 let msg = format!("{}", err);
2004 assert!(
2005 msg.contains("dangerous command blocked") || msg.contains("recursive rm"),
2006 "Should block r\"m\" -rf /, got: {}",
2007 msg
2008 );
2009 }
2010
2011 #[test]
2012 fn blocks_rm_via_single_quote_bypass() {
2013 let err = Capability::execute(
2015 &ShellExec,
2016 &serde_json::json!({"cmd": "'r''m' -rf /"}),
2017 &Context {
2018 dry_run: false,
2019 job_id: "test".into(),
2020 working_dir: std::env::temp_dir(),
2021 },
2022 )
2023 .unwrap_err();
2024 assert!(
2025 format!("{}", err).contains("recursive rm")
2026 || format!("{}", err).contains("rm command blocked"),
2027 "Should block 'r''m' -rf /"
2028 );
2029 }
2030
2031 #[test]
2032 fn blocks_rm_via_backslash_bypass() {
2033 let err = Capability::execute(
2035 &ShellExec,
2036 &serde_json::json!({"cmd": "r\\m -rf /"}),
2037 &Context {
2038 dry_run: false,
2039 job_id: "test".into(),
2040 working_dir: std::env::temp_dir(),
2041 },
2042 )
2043 .unwrap_err();
2044 assert!(
2045 format!("{}", err).contains("recursive rm")
2046 || format!("{}", err).contains("rm command blocked"),
2047 "Should block r\\m -rf /"
2048 );
2049 }
2050
2051 #[test]
2052 fn blocks_dd_via_quote_bypass() {
2053 let err = Capability::execute(
2055 &ShellExec,
2056 &serde_json::json!({"cmd": "d\"d\" if=/dev/zero"}),
2057 &Context {
2058 dry_run: false,
2059 job_id: "test".into(),
2060 working_dir: std::env::temp_dir(),
2061 },
2062 )
2063 .unwrap_err();
2064 assert!(
2065 format!("{}", err).contains("dd"),
2066 "Should block d\"d\" (dd bypass)"
2067 );
2068 }
2069
2070 #[test]
2073 fn blocks_cat_outside_allowed() {
2074 let err = Capability::execute(
2076 &ShellExec,
2077 &serde_json::json!({"cmd": "cat /etc/passwd"}),
2078 &Context {
2079 dry_run: false,
2080 job_id: "test".into(),
2081 working_dir: std::env::temp_dir(),
2082 },
2083 )
2084 .unwrap_err();
2085 assert!(
2086 format!("{}", err).contains("outside allowed directories"),
2087 "Should block cat /etc/passwd"
2088 );
2089 }
2090
2091 #[test]
2092 fn blocks_ls_tilde_ssh() {
2093 let err = Capability::execute(
2099 &ShellExec,
2100 &serde_json::json!({"cmd": "ls ~/../../etc/passwd"}),
2101 &Context {
2102 dry_run: false,
2103 job_id: "test".into(),
2104 working_dir: std::env::temp_dir(),
2105 },
2106 )
2107 .unwrap_err();
2108 let msg = format!("{}", err);
2110 assert!(
2111 msg.contains("outside allowed") || msg.contains("traversal"),
2112 "Should block ls ~/../../etc/passwd, got: {}",
2113 msg
2114 );
2115 }
2116
2117 #[test]
2118 fn blocks_path_to_root() {
2119 let err = Capability::execute(
2121 &ShellExec,
2122 &serde_json::json!({"cmd": "cat /root/.bashrc"}),
2123 &Context {
2124 dry_run: false,
2125 job_id: "test".into(),
2126 working_dir: std::env::temp_dir(),
2127 },
2128 )
2129 .unwrap_err();
2130 assert!(
2131 format!("{}", err).contains("outside allowed directories"),
2132 "Should block cat /root/.bashrc"
2133 );
2134 }
2135
2136 #[test]
2137 fn allows_cat_in_tmp() {
2138 let r = Capability::execute(
2141 &ShellExec,
2142 &serde_json::json!({"cmd": "echo test > /tmp/runtimo_path_test.txt && cat /tmp/runtimo_path_test.txt"}),
2143 &Context {
2144 dry_run: false,
2145 job_id: "test".into(),
2146 working_dir: std::env::temp_dir(),
2147 },
2148 );
2149 let _ = std::fs::remove_file("/tmp/runtimo_path_test.txt");
2151 match r {
2152 Ok(o) => assert_eq!(o.status, "ok", "Should allow cat in /tmp"),
2153 Err(e) => {
2154 let msg = format!("{}", e);
2155 assert!(
2156 !msg.contains("outside allowed"),
2157 "Should NOT block /tmp path, got: {}",
2158 msg
2159 );
2160 }
2161 }
2162 }
2163
2164 #[test]
2165 fn allows_cat_in_home() {
2166 let home = std::env::var("HOME").unwrap_or_else(|_| "/home/user".to_string());
2168 let test_path = format!("{}/runtimo_home_path_test.txt", home);
2169 let cmd = format!("echo ok > {} && cat {}", test_path, test_path);
2170 let r = Capability::execute(
2171 &ShellExec,
2172 &serde_json::json!({"cmd": cmd}),
2173 &Context {
2174 dry_run: false,
2175 job_id: "test".into(),
2176 working_dir: std::env::temp_dir(),
2177 },
2178 );
2179 let _ = std::fs::remove_file(&test_path);
2180 match r {
2181 Ok(o) => assert_eq!(o.status, "ok", "Should allow cat in HOME"),
2182 Err(e) => {
2183 let msg = format!("{}", e);
2184 assert!(
2185 !msg.contains("outside allowed"),
2186 "Should NOT block HOME path, got: {}",
2187 msg
2188 );
2189 }
2190 }
2191 }
2192
2193 #[test]
2196 fn blocks_var_expanded_path_outside_allowed() {
2197 let err = Capability::execute(
2202 &ShellExec,
2203 &serde_json::json!({"cmd": "cat $HOME/../../etc/shadow"}),
2204 &Context {
2205 dry_run: false,
2206 job_id: "test".into(),
2207 working_dir: std::env::temp_dir(),
2208 },
2209 )
2210 .unwrap_err();
2211 let msg = format!("{}", err);
2212 assert!(
2213 msg.contains("outside allowed") || msg.contains("traversal"),
2214 "Should block $HOME/../../etc/shadow, got: {}",
2215 msg
2216 );
2217 }
2218
2219 #[test]
2220 fn blocks_var_brace_expanded_path() {
2221 let err = Capability::execute(
2223 &ShellExec,
2224 &serde_json::json!({"cmd": "cat ${HOME}/../../etc/shadow"}),
2225 &Context {
2226 dry_run: false,
2227 job_id: "test".into(),
2228 working_dir: std::env::temp_dir(),
2229 },
2230 )
2231 .unwrap_err();
2232 let msg = format!("{}", err);
2233 assert!(
2234 msg.contains("outside allowed") || msg.contains("traversal"),
2235 "Should block brace-syntax var expansion"
2236 );
2237 }
2238
2239 #[test]
2240 fn test_expand_shell_vars_resolves_home() {
2241 let expanded = expand_shell_vars("$HOME/.ssh");
2243 let home = std::env::var("HOME").unwrap_or_default();
2244 assert!(
2245 expanded.starts_with(&home),
2246 "Should expand $HOME, got: {}",
2247 expanded
2248 );
2249 assert!(
2250 expanded.ends_with("/.ssh"),
2251 "Should keep suffix, got: {}",
2252 expanded
2253 );
2254 }
2255
2256 #[test]
2257 fn test_expand_shell_vars_brace_syntax() {
2258 let expanded = expand_shell_vars("${HOME}/.ssh");
2259 let home = std::env::var("HOME").unwrap_or_default();
2260 assert!(
2261 expanded.starts_with(&home),
2262 "Should expand brace-syntax var"
2263 );
2264 }
2265
2266 #[test]
2269 fn blocks_redirect_to_outside_path() {
2270 let err = Capability::execute(
2272 &ShellExec,
2273 &serde_json::json!({"cmd": "echo evil >/etc/cron.d/backdoor"}),
2274 &Context {
2275 dry_run: false,
2276 job_id: "test".into(),
2277 working_dir: std::env::temp_dir(),
2278 },
2279 )
2280 .unwrap_err();
2281 assert!(
2282 format!("{}", err).contains("outside allowed directories"),
2283 "Should block redirect to /etc/cron.d/backdoor"
2284 );
2285 }
2286
2287 #[test]
2288 fn blocks_append_redirect_to_outside_path() {
2289 let err = Capability::execute(
2291 &ShellExec,
2292 &serde_json::json!({"cmd": "echo evil >>/etc/hosts"}),
2293 &Context {
2294 dry_run: false,
2295 job_id: "test".into(),
2296 working_dir: std::env::temp_dir(),
2297 },
2298 )
2299 .unwrap_err();
2300 assert!(
2301 format!("{}", err).contains("outside allowed directories"),
2302 "Should block append redirect to /etc/hosts"
2303 );
2304 }
2305
2306 #[test]
2307 fn blocks_stderr_redirect_outside() {
2308 let err = Capability::execute(
2310 &ShellExec,
2311 &serde_json::json!({"cmd": "ls 2>/etc/malicious"}),
2312 &Context {
2313 dry_run: false,
2314 job_id: "test".into(),
2315 working_dir: std::env::temp_dir(),
2316 },
2317 )
2318 .unwrap_err();
2319 assert!(
2320 format!("{}", err).contains("outside allowed directories"),
2321 "Should block 2> redirect to /etc/malicious"
2322 );
2323 }
2324
2325 #[test]
2326 fn allows_redirect_to_allowed_path() {
2327 let r = Capability::execute(
2329 &ShellExec,
2330 &serde_json::json!({"cmd": "echo hello >/tmp/runtimo_redirect_test.txt"}),
2331 &Context {
2332 dry_run: false,
2333 job_id: "test".into(),
2334 working_dir: std::env::temp_dir(),
2335 },
2336 );
2337 let _ = std::fs::remove_file("/tmp/runtimo_redirect_test.txt");
2338 match r {
2339 Ok(o) => assert_eq!(o.status, "ok"),
2340 Err(e) => {
2341 assert!(
2342 !format!("{}", e).contains("outside allowed"),
2343 "Should NOT block redirect to /tmp, got: {}",
2344 e
2345 );
2346 }
2347 }
2348 }
2349
2350 #[test]
2353 fn blocks_relative_parent_traversal() {
2354 let err = Capability::execute(
2356 &ShellExec,
2357 &serde_json::json!({"cmd": "cat ../../etc/passwd"}),
2358 &Context {
2359 dry_run: false,
2360 job_id: "test".into(),
2361 working_dir: std::env::temp_dir(),
2362 },
2363 )
2364 .unwrap_err();
2365 assert!(
2366 format!("{}", err).contains("outside allowed directories"),
2367 "Should block relative path traversal to /etc/passwd"
2368 );
2369 }
2370
2371 #[test]
2372 fn blocks_deep_relative_traversal() {
2373 let err = Capability::execute(
2375 &ShellExec,
2376 &serde_json::json!({"cmd": "cat ./../../../etc/shadow"}),
2377 &Context {
2378 dry_run: false,
2379 job_id: "test".into(),
2380 working_dir: std::env::temp_dir(),
2381 },
2382 )
2383 .unwrap_err();
2384 assert!(
2385 format!("{}", err).contains("outside allowed directories"),
2386 "Should block deep relative traversal"
2387 );
2388 }
2389
2390 #[test]
2391 fn allows_relative_within_allowed() {
2392 let test_file = "/tmp/runtimo_relative_allowed_test.txt";
2397 let r = Capability::execute(
2398 &ShellExec,
2399 &serde_json::json!({"cmd": format!("echo ok > {}", test_file)}),
2400 &Context {
2401 dry_run: false,
2402 job_id: "test".into(),
2403 working_dir: std::env::temp_dir(),
2404 },
2405 );
2406 let _ = std::fs::remove_file(test_file);
2407 match r {
2408 Ok(o) => assert_eq!(o.status, "ok", "Should allow path within /tmp"),
2409 Err(e) => {
2410 let msg = format!("{}", e);
2411 assert!(
2412 !msg.contains("outside allowed"),
2413 "Should NOT block /tmp path, got: {}",
2414 msg
2415 );
2416 }
2417 }
2418 }
2419
2420 #[test]
2423 fn blocks_env_command() {
2424 let err = Capability::execute(
2425 &ShellExec,
2426 &serde_json::json!({"cmd": "env"}),
2427 &Context {
2428 dry_run: false,
2429 job_id: "test".into(),
2430 working_dir: std::env::temp_dir(),
2431 },
2432 )
2433 .unwrap_err();
2434 assert!(
2435 format!("{}", err).contains("environment variable dumping"),
2436 "Should block `env` command"
2437 );
2438 }
2439
2440 #[test]
2441 fn blocks_printenv_command() {
2442 let err = Capability::execute(
2443 &ShellExec,
2444 &serde_json::json!({"cmd": "printenv"}),
2445 &Context {
2446 dry_run: false,
2447 job_id: "test".into(),
2448 working_dir: std::env::temp_dir(),
2449 },
2450 )
2451 .unwrap_err();
2452 assert!(
2453 format!("{}", err).contains("environment variable dumping"),
2454 "Should block `printenv` command"
2455 );
2456 }
2457
2458 #[test]
2459 fn blocks_set_command() {
2460 let err = Capability::execute(
2461 &ShellExec,
2462 &serde_json::json!({"cmd": "set"}),
2463 &Context {
2464 dry_run: false,
2465 job_id: "test".into(),
2466 working_dir: std::env::temp_dir(),
2467 },
2468 )
2469 .unwrap_err();
2470 assert!(
2471 format!("{}", err).contains("environment variable dumping"),
2472 "Should block `set` command"
2473 );
2474 }
2475
2476 #[test]
2477 fn blocks_export_command() {
2478 let err = Capability::execute(
2479 &ShellExec,
2480 &serde_json::json!({"cmd": "export"}),
2481 &Context {
2482 dry_run: false,
2483 job_id: "test".into(),
2484 working_dir: std::env::temp_dir(),
2485 },
2486 )
2487 .unwrap_err();
2488 assert!(
2489 format!("{}", err).contains("environment variable dumping"),
2490 "Should block `export` command"
2491 );
2492 }
2493
2494 #[test]
2497 fn blocks_export_with_assignment() {
2498 let err = Capability::execute(
2501 &ShellExec,
2502 &serde_json::json!({"cmd": "export FOO=bar"}),
2503 &Context {
2504 dry_run: false,
2505 job_id: "test".into(),
2506 working_dir: std::env::temp_dir(),
2507 },
2508 )
2509 .unwrap_err();
2510 assert!(
2511 format!("{}", err).contains("environment variable dumping"),
2512 "Should block `export FOO=bar` (export with assignment)"
2513 );
2514 }
2515
2516 #[test]
2517 fn blocks_declare_p_command() {
2518 let err = Capability::execute(
2519 &ShellExec,
2520 &serde_json::json!({"cmd": "declare -p"}),
2521 &Context {
2522 dry_run: false,
2523 job_id: "test".into(),
2524 working_dir: std::env::temp_dir(),
2525 },
2526 )
2527 .unwrap_err();
2528 assert!(
2529 format!("{}", err).contains("environment variable dumping"),
2530 "Should block `declare -p` command"
2531 );
2532 }
2533
2534 #[test]
2535 fn blocks_env_via_quote_bypass() {
2536 let err = Capability::execute(
2538 &ShellExec,
2539 &serde_json::json!({"cmd": "e\"n\"v"}),
2540 &Context {
2541 dry_run: false,
2542 job_id: "test".into(),
2543 working_dir: std::env::temp_dir(),
2544 },
2545 )
2546 .unwrap_err();
2547 assert!(
2548 format!("{}", err).contains("environment variable dumping"),
2549 "Should block e\"n\"v (quoted env bypass)"
2550 );
2551 }
2552
2553 #[test]
2554 fn allows_harmless_command_with_env_check() {
2555 let r = Capability::execute(
2557 &ShellExec,
2558 &serde_json::json!({"cmd": "echo hello"}),
2559 &Context {
2560 dry_run: false,
2561 job_id: "test".into(),
2562 working_dir: std::env::temp_dir(),
2563 },
2564 )
2565 .unwrap();
2566 assert_eq!(r.status, "ok");
2567 assert!(r.data.as_ref().unwrap()["stdout"]
2568 .as_str()
2569 .unwrap()
2570 .contains("hello"));
2571 }
2572
2573 #[test]
2574 fn is_sensitive_env_var_detects_aws() {
2575 assert!(is_sensitive_env_var("AWS_ACCESS_KEY_ID"));
2576 assert!(is_sensitive_env_var("AWS_SECRET_ACCESS_KEY"));
2577 assert!(is_sensitive_env_var("aws_session_token")); }
2579
2580 #[test]
2581 fn is_sensitive_env_var_detects_suffixes() {
2582 assert!(is_sensitive_env_var("MYAPP_API_KEY"));
2583 assert!(is_sensitive_env_var("GITHUB_TOKEN"));
2584 assert!(is_sensitive_env_var("DB_PASSWORD"));
2585 assert!(is_sensitive_env_var("STRIPE_SECRET_KEY"));
2586 }
2587
2588 #[test]
2589 fn is_sensitive_env_var_allows_safe() {
2590 assert!(!is_sensitive_env_var("HOME"));
2591 assert!(!is_sensitive_env_var("USER"));
2592 assert!(!is_sensitive_env_var("PATH"));
2593 assert!(!is_sensitive_env_var("TERM"));
2594 assert!(!is_sensitive_env_var("LANG"));
2595 assert!(!is_sensitive_env_var("RUNTIMO_ENABLE_NETWORK"));
2596 }
2597
2598 #[test]
2601 fn is_sensitive_env_var_allows_known_non_secret_suffix() {
2602 assert!(!is_sensitive_env_var("FOREIGN_KEY"));
2604 assert!(!is_sensitive_env_var("PRIMARY_KEY"));
2605 assert!(!is_sensitive_env_var("PUBLIC_KEY"));
2606 assert!(!is_sensitive_env_var("BASE_URL"));
2607 }
2608
2609 #[test]
2612 fn is_sensitive_env_var_detects_ld_preload() {
2613 assert!(is_sensitive_env_var("LD_PRELOAD"));
2615 assert!(is_sensitive_env_var("LD_LIBRARY_PATH"));
2616 assert!(is_sensitive_env_var("LD_DEBUG"));
2617 assert!(is_sensitive_env_var("LD_BIND_NOW"));
2618 }
2619
2620 #[test]
2621 fn is_sensitive_env_var_detects_dyld() {
2622 assert!(is_sensitive_env_var("DYLD_INSERT_LIBRARIES"));
2624 assert!(is_sensitive_env_var("DYLD_LIBRARY_PATH"));
2625 }
2626
2627 #[test]
2628 fn sanitized_env_strips_secrets() {
2629 std::env::set_var("RUNTIMO_TEST_SECRET_KEY", "test-value");
2631 let env = sanitized_env();
2632 std::env::remove_var("RUNTIMO_TEST_SECRET_KEY");
2633
2634 assert!(
2635 !env.iter()
2636 .map(|(k, _)| k.as_str())
2637 .any(|x| x == "RUNTIMO_TEST_SECRET_KEY"),
2638 "RUNTIMO_TEST_SECRET_KEY should be stripped from env"
2639 );
2640 }
2641
2642 #[test]
2643 fn sanitized_env_preserves_safe() {
2644 let env = sanitized_env();
2645 let keys: Vec<&str> = env.iter().map(|(k, _)| k.as_str()).collect();
2646 assert!(keys.contains(&"HOME"), "HOME should be preserved");
2647 assert!(keys.contains(&"USER"), "USER should be preserved");
2648 }
2649}