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 let overrides = crate::config::RuntimoConfig::get_blocklist_overrides();
477 for pattern in &overrides {
478 if !pattern.is_empty()
479 && (cmd_lower.contains(pattern.as_str()) || detok_lower.contains(pattern.as_str()))
480 {
481 return Some("command blocked by config override");
482 }
483 }
484
485 None
486}
487
488#[must_use]
495pub fn is_network_command(cmd: &str) -> bool {
496 let cmd_lower = cmd.to_lowercase();
497 command_matches(
498 &cmd_lower,
499 &[
500 "curl", "wget", "nc", "ncat", "netcat", "socat", "ssh", "scp", "telnet",
501 ],
502 )
503}
504
505#[must_use]
509pub fn network_enabled() -> bool {
510 std::env::var("RUNTIMO_ENABLE_NETWORK").as_deref() == Ok("1")
511}
512
513#[must_use]
523pub fn is_interpreter_command(cmd: &str) -> bool {
524 let cmd_lower = cmd.to_lowercase();
525 command_matches(
526 &cmd_lower,
527 &[
528 "python", "python3", "python2", "perl", "ruby", "node", "lua", "php", "tclsh", "wish",
529 "racket", "guile", "ghci", "runghc", "scala", "gawk", "nawk",
530 ],
531 )
532}
533
534#[must_use]
540pub fn interpreters_enabled() -> bool {
541 std::env::var("RUNTIMO_ENABLE_INTERPRETERS").as_deref() == Ok("1")
542}
543
544#[must_use]
557pub fn detokenize_command(cmd: &str) -> String {
558 const MAX_PASSES: usize = 16;
559
560 let mut current = cmd.to_string();
561 let mut previous;
562 let mut passes: usize = 0;
563
564 loop {
565 previous = current.clone();
566 current = detokenize_single_pass(&previous);
567 passes = passes.saturating_add(1);
568 if current == previous || passes >= MAX_PASSES {
569 break;
570 }
571 }
572 current
573}
574
575#[must_use]
579fn detokenize_single_pass(cmd: &str) -> String {
580 let mut result = String::with_capacity(cmd.len());
581 let mut chars = cmd.chars().peekable();
582
583 while let Some(&c) = chars.peek() {
584 match c {
585 '\\' => {
586 chars.next(); if let Some(next) = chars.next() {
589 if next != '\n' {
593 result.push(next);
594 }
595 }
596 }
598 '$' => {
599 chars.next(); if chars.peek() == Some(&'\'') {
603 chars.next(); while let Some(ch) = chars.next() {
606 if ch == '\'' {
607 break; }
609 if ch == '\\' {
610 match chars.next() {
612 Some('\\') => result.push('\\'),
613 Some('\'') => result.push('\''),
614 Some('"') => result.push('"'),
615 Some('?') => result.push('?'),
616 Some('a') => result.push('a'),
617 Some('b') => result.push('b'),
618 Some('f') => result.push('f'),
619 Some('n' | 'r' | 't' | 'v') => {
620 result.push(' ');
622 }
623 Some('e' | 'E') => result.push('e'),
624 Some('x') => {
626 let mut hex = String::new();
627 for _ in 0..2 {
628 if let Some(&h) = chars.peek() {
629 if h.is_ascii_hexdigit() {
630 hex.push(h);
631 chars.next();
632 } else {
633 break;
634 }
635 }
636 }
637 if let Ok(byte) = u8::from_str_radix(&hex, 16) {
638 if let Some(c) = char::from_u32(u32::from(byte)) {
639 result.push(c);
640 }
641 }
642 }
643 Some('u') => {
645 let mut hex = String::new();
646 for _ in 0..4 {
647 if let Some(&h) = chars.peek() {
648 if h.is_ascii_hexdigit() {
649 hex.push(h);
650 chars.next();
651 } else {
652 break;
653 }
654 }
655 }
656 if let Ok(cp) = u32::from_str_radix(&hex, 16) {
657 if let Some(c) = char::from_u32(cp) {
658 result.push(c);
659 }
660 }
661 }
662 Some('U') => {
664 let mut hex = String::new();
665 for _ in 0..8 {
666 if let Some(&h) = chars.peek() {
667 if h.is_ascii_hexdigit() {
668 hex.push(h);
669 chars.next();
670 } else {
671 break;
672 }
673 }
674 }
675 if let Ok(cp) = u32::from_str_radix(&hex, 16) {
676 if let Some(c) = char::from_u32(cp) {
677 result.push(c);
678 }
679 }
680 }
681 Some(d) if ('0'..='7').contains(&d) => {
683 let mut octal = String::from(d);
684 for _ in 0..2 {
685 if let Some(&od) = chars.peek() {
686 if ('0'..='7').contains(&od) {
687 octal.push(od);
688 chars.next();
689 } else {
690 break;
691 }
692 }
693 }
694 if let Ok(byte) = u8::from_str_radix(&octal, 8) {
695 if let Some(c) = char::from_u32(u32::from(byte)) {
696 result.push(c);
697 }
698 }
699 }
700 Some(ctrl) => result.push(ctrl),
702 None => {}
704 }
705 } else {
706 result.push(ch);
707 }
708 }
709 } else {
710 result.push('$');
712 }
713 }
714 '\'' => {
715 chars.next(); for ch in chars.by_ref() {
718 if ch == '\'' {
719 break; }
721 result.push(ch);
722 }
723 }
724 '"' => {
725 chars.next(); while let Some(ch) = chars.next() {
728 if ch == '"' {
729 break; }
731 if ch == '\\' {
732 if let Some(&next_ch) = chars.peek() {
733 match next_ch {
734 '"' | '$' | '`' | '\\' | '\n' => {
735 chars.next(); result.push(next_ch);
737 continue;
738 }
739 _ => {
740 result.push('\\');
742 continue;
744 }
745 }
746 }
747 result.push('\\');
749 break;
750 }
751 result.push(ch);
752 }
753 }
754 _ => {
755 result.push(c);
756 chars.next(); }
758 }
759 }
760 result
761}
762
763#[must_use]
772fn is_sensitive_env_var(key: &str) -> bool {
773 if SAFE_ENV_VARS.contains(&key) {
775 return false;
776 }
777 let key_upper = key.to_uppercase();
778 if SENSITIVE_ENV_PREFIXES
780 .iter()
781 .any(|prefix| key_upper.starts_with(prefix))
782 {
783 return true;
784 }
785 SENSITIVE_ENV_SUFFIXES
787 .iter()
788 .any(|suffix| key_upper.ends_with(suffix))
789}
790
791#[must_use]
798fn sanitized_env() -> Vec<(String, String)> {
799 std::env::vars()
800 .filter(|(key, _)| !is_sensitive_env_var(key))
801 .collect()
802}
803
804#[must_use]
814pub fn is_env_dumping_command(cmd: &str) -> bool {
815 let cmd_lower = cmd.to_lowercase().trim().to_string();
816 let detok_lower = detokenize_command(&cmd_lower);
818
819 let env_dumpers: &[&str] = &[
820 "env", "printenv", "set", "export", "declare", "typeset", "compgen",
821 ];
822
823 for dumper in env_dumpers {
824 if command_matches(&cmd_lower, &[dumper]) {
826 return true;
827 }
828 if command_matches(&detok_lower, &[dumper]) {
830 return true;
831 }
832 }
833 false
834}
835
836#[must_use]
847fn is_path_within_allowed(path_str: &str, allowed: &[String]) -> bool {
848 allowed
849 .iter()
850 .filter(|prefix| !prefix.is_empty())
851 .any(|prefix| path_str == prefix || path_str.starts_with(&format!("{}/", prefix)))
852}
853
854#[must_use]
864fn expand_shell_vars(token: &str) -> String {
865 let mut result = String::with_capacity(token.len());
866 let mut chars = token.chars().peekable();
867 let mut expanded_any = false;
868
869 while let Some(&c) = chars.peek() {
870 if c == '$' {
871 chars.next(); if chars.peek() == Some(&'{') {
874 chars.next(); let mut var_name = String::new();
876 while let Some(&ch) = chars.peek() {
877 if ch == '}' {
878 chars.next(); break;
880 }
881 var_name.push(ch);
882 chars.next();
883 }
884 if let Ok(value) = std::env::var(&var_name) {
886 result.push_str(&value);
887 expanded_any = true;
888 } else {
889 result.push('$');
891 result.push('{');
892 result.push_str(&var_name);
893 result.push('}');
894 }
895 } else {
896 let mut var_name = String::new();
898 while let Some(&ch) = chars.peek() {
899 if ch.is_alphanumeric() || ch == '_' {
900 var_name.push(ch);
901 chars.next();
902 } else {
903 break;
904 }
905 }
906 if var_name.is_empty() {
907 result.push('$');
909 } else if let Ok(value) = std::env::var(&var_name) {
910 result.push_str(&value);
911 expanded_any = true;
912 } else {
913 result.push('$');
915 result.push_str(&var_name);
916 }
917 }
918 } else {
919 result.push(c);
920 chars.next();
921 }
922 }
923
924 if expanded_any {
925 result
926 } else {
927 token.to_string()
928 }
929}
930
931#[must_use]
954fn check_command_paths(cmd: &str) -> Option<String> {
955 let allowed = RuntimoConfig::get_allowed_prefixes();
956 let detok = detokenize_command(cmd);
957
958 for token in detok.split_whitespace() {
960 let path = token.trim_matches(|c: char| c == '"' || c == '\'' || c == '`' || c == ',');
962
963 if path.is_empty() || path.len() < 2 {
965 continue;
966 }
967
968 if path.starts_with('-') {
970 continue;
971 }
972
973 let path_without_redirect = path
978 .trim_start_matches("&>>")
979 .trim_start_matches("2>>")
980 .trim_start_matches("1>>")
981 .trim_start_matches("&>")
982 .trim_start_matches("2>")
983 .trim_start_matches("1>")
984 .trim_start_matches(">>")
985 .trim_start_matches('>')
986 .trim_start_matches('<');
987
988 let path = if path_without_redirect != path
990 && (path_without_redirect.starts_with('/') || path_without_redirect.starts_with("~/"))
991 {
992 path_without_redirect
993 } else {
994 path
995 };
996
997 if path.contains('=') && !path.starts_with('/') && !path.starts_with("~/") {
999 continue;
1000 }
1001
1002 let resolved = if path.contains('$') {
1007 let expanded = expand_shell_vars(path);
1008 if expanded == path {
1009 continue;
1012 }
1013 if expanded.starts_with('/') {
1015 let clean = expanded.trim_end_matches(|c: char| {
1017 c == ';' || c == '|' || c == '&' || c == '>' || c == '<'
1018 });
1019 clean.to_string()
1020 } else {
1021 continue;
1023 }
1024 } else if path.starts_with("~/") {
1025 match std::env::var("HOME") {
1026 Ok(home) => {
1027 let mut home_path = home.trim_end_matches('/').to_string();
1028 home_path.push_str(&path[1..]); home_path
1030 }
1031 Err(_) => continue, }
1033 } else if path.starts_with('/') {
1034 let clean_end = path.trim_end_matches(|c: char| {
1037 c == ';' || c == '|' || c == '&' || c == '>' || c == '<'
1038 });
1039 clean_end.to_string()
1040 } else if path.starts_with('.') {
1041 let Ok(cwd) = std::env::current_dir() else {
1046 continue;
1047 };
1048 let joined = cwd.join(path);
1049 let mut components: Vec<&str> = Vec::new();
1051 for component in joined.components() {
1052 match component {
1053 std::path::Component::ParentDir => {
1054 components.pop(); }
1056 std::path::Component::Normal(os_str) => {
1057 if let Some(s) = os_str.to_str() {
1058 components.push(s);
1059 }
1060 }
1061 std::path::Component::RootDir => {
1062 components.clear();
1064 }
1065 _ => {}
1067 }
1068 }
1069 let normalized = format!("/{}", components.join("/"));
1070 if normalized.contains("/../") || normalized.contains("/..") || normalized == "/.." {
1072 return Some(format!(
1073 "ShellExec blocked: path traversal not allowed: {}",
1074 path
1075 ));
1076 }
1077 normalized
1078 } else {
1079 continue;
1080 };
1081
1082 if resolved == "/" {
1084 continue;
1085 }
1086
1087 if resolved.contains("/../") || resolved.contains("/..") || resolved == ".." {
1090 return Some(format!(
1091 "ShellExec blocked: path traversal not allowed: {}",
1092 path
1093 ));
1094 }
1095
1096 if resolved.starts_with("/dev/") {
1098 continue;
1099 }
1100
1101 if resolved.starts_with("/proc/") || resolved.starts_with("/sys/") {
1103 continue;
1104 }
1105
1106 if !is_path_within_allowed(&resolved, &allowed) {
1107 let display_path = if path.starts_with("~/") {
1109 path.to_string()
1110 } else {
1111 resolved
1112 };
1113 return Some(format!(
1114 "ShellExec blocked: path is outside allowed directories: {}",
1115 display_path
1116 ));
1117 }
1118 }
1119
1120 None
1121}
1122
1123#[allow(clippy::arithmetic_side_effects)] fn wait_with_timeout(child: &mut Child, pgid: u32, timeout_secs: u64) -> WaitResult {
1125 let start = Instant::now();
1126 let timeout = Duration::from_secs(timeout_secs);
1127 let child_pid = child.id();
1128 let stdout_thread = child.stdout.take().map(|stdout| {
1129 thread::spawn(move || {
1130 let mut data = Vec::new();
1131 let _ = stdout.take(MAX_OUTPUT_BYTES as u64).read_to_end(&mut data);
1132 data
1133 })
1134 });
1135 let stderr_thread = child.stderr.take().map(|stderr| {
1136 thread::spawn(move || {
1137 let mut data = Vec::new();
1138 let _ = stderr.take(MAX_OUTPUT_BYTES as u64).read_to_end(&mut data);
1139 data
1140 })
1141 });
1142 let mut last_descendants: Vec<u32>;
1143 loop {
1144 if start.elapsed() > timeout {
1145 #[allow(clippy::cast_possible_wrap)]
1148 unsafe {
1149 let _ = libc::kill(-(pgid as libc::pid_t), libc::SIGKILL);
1150 }
1151 let killed_descendants = get_all_descendants(child_pid);
1152 let _ = child.wait();
1153 let _ = stdout_thread.map(|h| h.join().unwrap_or_default());
1154 let _ = stderr_thread.map(|h| h.join().unwrap_or_default());
1155 return Err(Error::ExecutionFailed(format!(
1156 "command timed out after {}s (killed {} descendants)",
1157 timeout_secs,
1158 killed_descendants.len()
1159 )));
1160 }
1161 last_descendants = get_all_descendants(child_pid);
1162 match child.try_wait() {
1163 Ok(Some(status)) => {
1164 let stdout_data = stdout_thread
1165 .map(|h| h.join().unwrap_or_default())
1166 .unwrap_or_default();
1167 let stderr_data = stderr_thread
1168 .map(|h| h.join().unwrap_or_default())
1169 .unwrap_or_default();
1170 return Ok((status, stdout_data, stderr_data, last_descendants));
1171 }
1172 Ok(None) => std::thread::sleep(Duration::from_millis(50)),
1173 Err(e) => return Err(Error::ExecutionFailed(format!("error waiting: {}", e))),
1174 }
1175 }
1176}
1177
1178fn get_direct_children(pid: u32) -> Vec<u32> {
1179 let children_path = format!("/proc/{}/children", pid);
1180 if let Ok(content) = fs::read_to_string(&children_path) {
1181 content
1182 .split_whitespace()
1183 .filter_map(|s| s.parse::<u32>().ok())
1184 .collect()
1185 } else {
1186 Vec::new()
1187 }
1188}
1189
1190fn get_all_descendants(pid: u32) -> Vec<u32> {
1191 let mut descendants = Vec::new();
1192 let mut stack = vec![pid];
1193 let mut visited = std::collections::HashSet::new();
1194 while let Some(current) = stack.pop() {
1195 if visited.contains(¤t) {
1196 continue;
1197 }
1198 visited.insert(current);
1199 let children = get_direct_children(current);
1200 if children.is_empty() {
1201 if let Ok(output) = std::process::Command::new("pgrep")
1202 .arg("-P")
1203 .arg(current.to_string())
1204 .output()
1205 {
1206 if output.status.success() {
1207 let pgrep_lines = String::from_utf8_lossy(&output.stdout).to_string();
1208 let pgrep_children = pgrep_lines
1209 .lines()
1210 .filter_map(|s| s.trim().parse::<u32>().ok());
1211 for child in pgrep_children {
1212 if !visited.contains(&child) {
1213 descendants.push(child);
1214 stack.push(child);
1215 }
1216 }
1217 continue;
1218 }
1219 }
1220 }
1221 for child in children {
1222 if !visited.contains(&child) {
1223 descendants.push(child);
1224 stack.push(child);
1225 }
1226 }
1227 }
1228 descendants
1229}
1230
1231#[allow(clippy::exhaustive_structs)]
1237pub struct ShellExec;
1238
1239impl TypedCapability for ShellExec {
1240 type Args = ShellExecArgs;
1241
1242 fn name(&self) -> &'static str {
1243 "ShellExec"
1244 }
1245 fn description(&self) -> &'static str {
1246 "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."
1247 }
1248 fn schema(&self) -> Value {
1249 serde_json::json!({
1250 "type": "object",
1251 "properties": {
1252 "cmd": { "type": "string", "description": "Command to execute via sh -c (max 65536 bytes)", "minLength": 1, "maxLength": 65536 },
1253 "timeout_secs": { "type": "integer", "minimum": 1, "maximum": 300 },
1254 "cwd": { "type": "string" },
1255 "stdin": { "type": "string" }
1256 },
1257 "required": ["cmd"]
1258 })
1259 }
1260 fn execute(
1261 &self,
1262 args: ShellExecArgs,
1263 ctx: &Context,
1264 ) -> std::result::Result<Output, CapabilityError> {
1265 if let Some(secs) = args.timeout_secs {
1268 if !(1..=300).contains(&secs) {
1269 return Err(CapabilityError::InvalidArgs(format!(
1270 "timeout_secs must be between 1 and 300, got {}",
1271 secs
1272 )));
1273 }
1274 }
1275 let timeout = args.timeout_secs.unwrap_or(DEFAULT_TIMEOUT_SECS);
1276
1277 let max_cmd_len: usize = 65536;
1278
1279 if args.cmd.trim().is_empty() {
1281 return Err(CapabilityError::InvalidArgs(
1282 "command is empty or contains only whitespace".into(),
1283 ));
1284 }
1285
1286 if args.cmd.len() > max_cmd_len {
1288 return Err(CapabilityError::InvalidArgs(format!(
1289 "command too long ({} bytes, max {})",
1290 args.cmd.len(),
1291 max_cmd_len
1292 )));
1293 }
1294
1295 if let Some(reason) = is_dangerous_command(&args.cmd) {
1298 return Err(CapabilityError::PermissionDenied(format!(
1299 "dangerous command blocked: {}",
1300 reason
1301 )));
1302 }
1303
1304 if !network_enabled() && is_network_command(&args.cmd) {
1307 return Err(CapabilityError::PermissionDenied(
1308 "network commands blocked — set RUNTIMO_ENABLE_NETWORK=1 to enable".into(),
1309 ));
1310 }
1311
1312 if !interpreters_enabled() && is_interpreter_command(&args.cmd) {
1316 return Err(CapabilityError::PermissionDenied(
1317 "interpreter commands blocked — set RUNTIMO_ENABLE_INTERPRETERS=1 to enable".into(),
1318 ));
1319 }
1320
1321 if let Some(reason) = check_command_paths(&args.cmd) {
1323 return Err(CapabilityError::PermissionDenied(reason));
1324 }
1325
1326 if ctx.dry_run {
1329 let mut out = Output::ok("DRY RUN".into());
1330 out.data = Some(serde_json::json!({ "cmd": &args.cmd, "dry_run": true }));
1331 return Ok(out);
1332 }
1333
1334 let mut cmd = Command::new("sh");
1335 cmd.env("PATH", "/usr/local/bin:/usr/bin:/bin");
1340
1341 let safe_env = sanitized_env();
1345 for (key, value) in &safe_env {
1346 if key == "PATH" {
1348 continue;
1349 }
1350 cmd.env(key, value);
1351 }
1352
1353 cmd.arg("-c").arg(&args.cmd);
1354 if let Some(cwd) = &args.cwd {
1355 let path_ctx = PathContext {
1356 require_exists: true,
1357 require_file: false,
1358 ..Default::default()
1359 };
1360 let cwd_path = validate_path(cwd, &path_ctx)
1361 .map_err(|e| CapabilityError::PermissionDenied(format!("invalid cwd: {}", e)))?;
1362 cmd.current_dir(cwd_path);
1363 }
1364 let mut child = cmd
1365 .process_group(0)
1366 .stdout(std::process::Stdio::piped())
1367 .stderr(std::process::Stdio::piped())
1368 .stdin(if args.stdin.is_some() {
1369 std::process::Stdio::piped()
1370 } else {
1371 std::process::Stdio::null()
1372 })
1373 .spawn()
1374 .map_err(|e| {
1375 CapabilityError::Io(std::io::Error::other(format!("failed to spawn: {}", e)))
1376 })?;
1377 let child_pid = child.id();
1378 let pgid = child_pid;
1379 if let Some(ref stdin_content) = args.stdin {
1380 if stdin_content.len() > MAX_STDIN_BYTES {
1381 return Err(CapabilityError::InvalidArgs("stdin too large".into()));
1382 }
1383 if let Some(mut stdin_pipe) = child.stdin.take() {
1384 let _ = stdin_pipe.write_all(stdin_content.as_bytes());
1385 }
1386 }
1387 let (exit_status, stdout, stderr, _descendants) =
1388 wait_with_timeout(&mut child, pgid, timeout)
1389 .map_err(|e| CapabilityError::Internal(e.to_string()))?;
1390 let stdout_str = String::from_utf8_lossy(&stdout).to_string();
1391 let stderr_str = String::from_utf8_lossy(&stderr).to_string();
1392 let success = exit_status.success();
1393
1394 let mut out = if success {
1395 Output::ok("completed".into())
1396 } else {
1397 Output::error(
1398 format!("exit code {}", exit_status.code().unwrap_or(-1)),
1399 format!("exit code {}", exit_status.code().unwrap_or(-1)),
1400 )
1401 };
1402 out.data = Some(
1403 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 }),
1404 );
1405 Ok(out)
1406 }
1407}
1408
1409#[cfg(test)]
1410mod tests {
1411 use super::*;
1412 use crate::capability::Capability;
1413 use std::time::Instant;
1414 #[test]
1415 fn executes_uptime() {
1416 let r = Capability::execute(
1417 &ShellExec,
1418 &serde_json::json!({"cmd": "uptime"}),
1419 &Context {
1420 dry_run: false,
1421 job_id: "test".into(),
1422 working_dir: std::env::temp_dir(),
1423 },
1424 )
1425 .unwrap();
1426 assert_eq!(r.status, "ok");
1427 }
1428 #[test]
1429 fn pipes_work() {
1430 let r = Capability::execute(
1431 &ShellExec,
1432 &serde_json::json!({"cmd": "echo hi | cat"}),
1433 &Context {
1434 dry_run: false,
1435 job_id: "test".into(),
1436 working_dir: std::env::temp_dir(),
1437 },
1438 )
1439 .unwrap();
1440 assert_eq!(r.status, "ok");
1441 assert!(r.data.as_ref().unwrap()["stdout"]
1442 .as_str()
1443 .unwrap()
1444 .contains("hi"));
1445 }
1446 #[test]
1447 fn chaining_works() {
1448 let r = Capability::execute(
1449 &ShellExec,
1450 &serde_json::json!({"cmd": "echo a && echo b"}),
1451 &Context {
1452 dry_run: false,
1453 job_id: "test".into(),
1454 working_dir: std::env::temp_dir(),
1455 },
1456 )
1457 .unwrap();
1458 assert_eq!(r.status, "ok");
1459 }
1460 #[test]
1461 fn blocks_dangerous() {
1462 assert!(Capability::execute(
1463 &ShellExec,
1464 &serde_json::json!({"cmd": "mkfs"}),
1465 &Context {
1466 dry_run: false,
1467 job_id: "test".into(),
1468 working_dir: std::env::temp_dir(),
1469 }
1470 )
1471 .is_err());
1472 }
1473 #[test]
1474 fn blocks_recursive_flag() {
1475 assert!(Capability::execute(
1477 &ShellExec,
1478 &serde_json::json!({"cmd": "rm --recursive /home"}),
1479 &Context {
1480 dry_run: false,
1481 job_id: "test".into(),
1482 working_dir: std::env::temp_dir(),
1483 }
1484 )
1485 .is_err());
1486 }
1487 #[test]
1488 fn blocks_rm_rf_root() {
1489 assert!(Capability::execute(
1491 &ShellExec,
1492 &serde_json::json!({"cmd": "rm -rf /"}),
1493 &Context {
1494 dry_run: false,
1495 job_id: "test".into(),
1496 working_dir: std::env::temp_dir(),
1497 }
1498 )
1499 .is_err());
1500 }
1501 #[test]
1502 fn blocks_rm_no_preserve_root() {
1503 assert!(Capability::execute(
1504 &ShellExec,
1505 &serde_json::json!({"cmd": "rm --no-preserve-root -rf /"}),
1506 &Context {
1507 dry_run: false,
1508 job_id: "test".into(),
1509 working_dir: std::env::temp_dir(),
1510 }
1511 )
1512 .is_err());
1513 }
1514 #[test]
1515 fn blocks_ownership_commands() {
1516 for cmd in &["chown root /tmp/x", "chgrp staff /tmp/x"] {
1517 assert!(
1518 Capability::execute(
1519 &ShellExec,
1520 &serde_json::json!({"cmd": cmd}),
1521 &Context {
1522 dry_run: false,
1523 job_id: "test".into(),
1524 working_dir: std::env::temp_dir(),
1525 }
1526 )
1527 .is_err(),
1528 "should block: {}",
1529 cmd
1530 );
1531 }
1532 }
1533 #[test]
1534 fn blocks_mount_commands() {
1535 for cmd in &["mount /dev/sda1 /mnt", "umount /mnt"] {
1536 assert!(
1537 Capability::execute(
1538 &ShellExec,
1539 &serde_json::json!({"cmd": cmd}),
1540 &Context {
1541 dry_run: false,
1542 job_id: "test".into(),
1543 working_dir: std::env::temp_dir(),
1544 }
1545 )
1546 .is_err(),
1547 "should block: {}",
1548 cmd
1549 );
1550 }
1551 }
1552 #[test]
1553 fn blocks_firewall_commands() {
1554 for cmd in &["iptables -L", "nft list ruleset"] {
1555 assert!(
1556 Capability::execute(
1557 &ShellExec,
1558 &serde_json::json!({"cmd": cmd}),
1559 &Context {
1560 dry_run: false,
1561 job_id: "test".into(),
1562 working_dir: std::env::temp_dir(),
1563 }
1564 )
1565 .is_err(),
1566 "should block: {}",
1567 cmd
1568 );
1569 }
1570 }
1571 #[test]
1572 fn blocks_network_commands_by_default() {
1573 std::env::remove_var("RUNTIMO_ENABLE_NETWORK");
1575 for cmd in &[
1576 "curl http://example.com",
1577 "wget http://example.com",
1578 "nc example.com 80",
1579 ] {
1580 assert!(
1581 Capability::execute(
1582 &ShellExec,
1583 &serde_json::json!({"cmd": cmd}),
1584 &Context {
1585 dry_run: false,
1586 job_id: "test".into(),
1587 working_dir: std::env::temp_dir(),
1588 }
1589 )
1590 .is_err(),
1591 "should block network cmd: {}",
1592 cmd
1593 );
1594 }
1595 }
1596 #[test]
1597 fn allows_network_commands_when_enabled() {
1598 std::env::set_var("RUNTIMO_ENABLE_NETWORK", "1");
1599 let r = Capability::execute(
1601 &ShellExec,
1602 &serde_json::json!({"cmd": "curl --version"}),
1603 &Context {
1604 dry_run: false,
1605 job_id: "test".into(),
1606 working_dir: std::env::temp_dir(),
1607 },
1608 );
1609 std::env::remove_var("RUNTIMO_ENABLE_NETWORK");
1610 match r {
1612 Ok(o) => assert_eq!(o.status, "ok", "curl --version should succeed when enabled"),
1613 Err(e) => {
1614 let msg = e.to_string();
1615 assert!(
1616 !msg.contains("network commands blocked"),
1617 "should NOT block network when RUNTIMO_ENABLE_NETWORK=1, got: {}",
1618 msg
1619 );
1620 }
1621 }
1622 }
1623 #[test]
1624 fn enforces_timeout() {
1625 let s = Instant::now();
1626 assert!(Capability::execute(
1627 &ShellExec,
1628 &serde_json::json!({"cmd": "sleep 5", "timeout_secs": 1}),
1629 &Context {
1630 dry_run: false,
1631 job_id: "test".into(),
1632 working_dir: std::env::temp_dir(),
1633 }
1634 )
1635 .is_err());
1636 assert!(s.elapsed().as_secs() < 3);
1637 }
1638
1639 #[test]
1644 fn detokenize_ansi_c_tab_expansion() {
1645 let detok = detokenize_command("$'rm\\t-rf\\t/'");
1647 assert!(detok.contains("rm"));
1649 assert!(detok.contains("-rf"));
1650 assert!(detok.contains('/'));
1651 }
1652
1653 #[test]
1654 fn detokenize_ansi_c_plain_content() {
1655 let detok = detokenize_command("$'rm -rf /'");
1657 assert!(detok.contains("rm -rf /"));
1658 }
1659
1660 #[test]
1661 fn detokenize_ansi_c_hex_escape() {
1662 let detok = detokenize_command("$'\\x72\\x6d'");
1664 assert!(
1665 detok.contains("rm"),
1666 "Hex-encoded 'rm' should decode, got: {:?}",
1667 detok
1668 );
1669 }
1670
1671 #[test]
1672 fn detokenize_ansi_c_unicode_escape() {
1673 let detok = detokenize_command("$'\\u0072\\u006d'");
1675 assert!(
1676 detok.contains("rm"),
1677 "Unicode-encoded 'rm' should decode, got: {:?}",
1678 detok
1679 );
1680 }
1681
1682 #[test]
1683 fn detokenize_ansi_c_octal_escape() {
1684 let detok = detokenize_command("$'\\162\\155'");
1686 assert!(
1687 detok.contains("rm"),
1688 "Octal-encoded 'rm' should decode, got: {:?}",
1689 detok
1690 );
1691 }
1692
1693 #[test]
1694 fn detokenize_ansi_c_newline_expansion() {
1695 let detok = detokenize_command("$'rm\\n-rf\\n/'");
1697 assert!(detok.contains("rm"));
1698 assert!(detok.contains("-rf"));
1699 }
1700
1701 #[test]
1702 fn detokenize_ansi_c_combined_escapes() {
1703 let detok = detokenize_command("$'m\\x6bfs'");
1705 assert!(
1706 detok.contains("mkfs"),
1707 "Should decode to mkfs, got: {:?}",
1708 detok
1709 );
1710 }
1711
1712 #[test]
1713 fn blocks_rm_via_ansi_c_bypass() {
1714 let err = Capability::execute(
1716 &ShellExec,
1717 &serde_json::json!({"cmd": "$'rm\\t-rf\\t/'"}),
1718 &Context {
1719 dry_run: false,
1720 job_id: "test".into(),
1721 working_dir: std::env::temp_dir(),
1722 },
1723 )
1724 .unwrap_err();
1725 let msg = format!("{}", err);
1726 assert!(
1727 msg.contains("recursive rm") || msg.contains("dangerous command blocked"),
1728 "Should block ANSI-C quoted rm, got: {}",
1729 msg
1730 );
1731 }
1732
1733 #[test]
1734 fn blocks_rm_via_ansi_c_hex_bypass() {
1735 let err = Capability::execute(
1737 &ShellExec,
1738 &serde_json::json!({"cmd": "$'\\x72\\x6d' -rf /"}),
1739 &Context {
1740 dry_run: false,
1741 job_id: "test".into(),
1742 working_dir: std::env::temp_dir(),
1743 },
1744 )
1745 .unwrap_err();
1746 assert!(
1747 format!("{}", err).contains("recursive rm")
1748 || format!("{}", err).contains("rm command blocked"),
1749 "Should block hex-encoded rm bypass"
1750 );
1751 }
1752
1753 #[test]
1756 fn blocks_chmod_via_quote_bypass() {
1757 let err = Capability::execute(
1759 &ShellExec,
1760 &serde_json::json!({"cmd": "c\"hmod\" 777 /"}),
1761 &Context {
1762 dry_run: false,
1763 job_id: "test".into(),
1764 working_dir: std::env::temp_dir(),
1765 },
1766 )
1767 .unwrap_err();
1768 assert!(
1769 format!("{}", err).contains("chmod"),
1770 "Should block c\"hmod\" 777 / (quoted chmod bypass)"
1771 );
1772 }
1773
1774 #[test]
1779 fn detokenize_backslash_newline_continuation() {
1780 let cmd_with_newline = "r\\\nm -rf /";
1782 let detok = detokenize_command(cmd_with_newline);
1783 assert!(
1784 detok.contains("rm"),
1785 "Backslash-newline should be stripped, got: {:?}",
1786 detok
1787 );
1788 assert!(
1789 detok.contains("rm -rf /"),
1790 "Should rejoin tokens across newline, got: {:?}",
1791 detok
1792 );
1793 }
1794
1795 #[test]
1796 fn blocks_rm_via_backslash_newline_bypass() {
1797 let err = Capability::execute(
1799 &ShellExec,
1800 &serde_json::json!({"cmd": "r\\\nm -rf /"}),
1801 &Context {
1802 dry_run: false,
1803 job_id: "test".into(),
1804 working_dir: std::env::temp_dir(),
1805 },
1806 )
1807 .unwrap_err();
1808 assert!(
1809 format!("{}", err).contains("recursive rm")
1810 || format!("{}", err).contains("rm command blocked"),
1811 "Should block backslash-newline rm bypass"
1812 );
1813 }
1814
1815 #[test]
1816 fn detokenize_multi_pass_stability() {
1817 let detok = detokenize_command("\"'rm'\" -rf /");
1819 assert!(
1821 detok.contains("rm"),
1822 "Multi-pass should converge, got: {:?}",
1823 detok
1824 );
1825 }
1826
1827 #[test]
1828 fn detokenize_roundtrip_idempotent() {
1829 let cmd = "r\"m\" -rf /";
1831 let detok1 = detokenize_command(cmd);
1832 let detok2 = detokenize_command(&detok1);
1833 assert_eq!(detok1, detok2, "Detokenization should be idempotent");
1834 }
1835
1836 #[test]
1839 fn blocks_heredoc() {
1840 let err = Capability::execute(
1842 &ShellExec,
1843 &serde_json::json!({"cmd": "cat <<EOF\nevil\nEOF"}),
1844 &Context {
1845 dry_run: false,
1846 job_id: "test".into(),
1847 working_dir: std::env::temp_dir(),
1848 },
1849 )
1850 .unwrap_err();
1851 let msg = format!("{}", err);
1852 assert!(
1853 msg.contains("heredoc"),
1854 "Should block heredoc (<<), got: {}",
1855 msg
1856 );
1857 }
1858
1859 #[test]
1860 fn blocks_herestring() {
1861 let err = Capability::execute(
1863 &ShellExec,
1864 &serde_json::json!({"cmd": "cat <<<\"hello\""}),
1865 &Context {
1866 dry_run: false,
1867 job_id: "test".into(),
1868 working_dir: std::env::temp_dir(),
1869 },
1870 )
1871 .unwrap_err();
1872 assert!(
1873 format!("{}", err).contains("heredoc"),
1874 "Should block herestring (<<<)"
1875 );
1876 }
1877
1878 #[test]
1879 fn blocks_heredoc_via_quote_bypass() {
1880 let err = Capability::execute(
1882 &ShellExec,
1883 &serde_json::json!({"cmd": "<\"<\"EOF\nevil\nEOF"}),
1884 &Context {
1885 dry_run: false,
1886 job_id: "test".into(),
1887 working_dir: std::env::temp_dir(),
1888 },
1889 )
1890 .unwrap_err();
1891 assert!(
1892 format!("{}", err).contains("heredoc"),
1893 "Should block quoted heredoc bypass"
1894 );
1895 }
1896
1897 #[test]
1900 fn blocks_process_substitution_input() {
1901 let err = Capability::execute(
1903 &ShellExec,
1904 &serde_json::json!({"cmd": "diff <(curl http://evil) <(ls)"}),
1905 &Context {
1906 dry_run: false,
1907 job_id: "test".into(),
1908 working_dir: std::env::temp_dir(),
1909 },
1910 )
1911 .unwrap_err();
1912 assert!(
1913 format!("{}", err).contains("process substitution"),
1914 "Should block <( ) process substitution"
1915 );
1916 }
1917
1918 #[test]
1919 fn blocks_process_substitution_output() {
1920 let err = Capability::execute(
1922 &ShellExec,
1923 &serde_json::json!({"cmd": "echo evil > >(tee /etc/cron.d/backdoor)"}),
1924 &Context {
1925 dry_run: false,
1926 job_id: "test".into(),
1927 working_dir: std::env::temp_dir(),
1928 },
1929 )
1930 .unwrap_err();
1931 assert!(
1932 format!("{}", err).contains("process substitution"),
1933 "Should block >( ) process substitution"
1934 );
1935 }
1936
1937 #[test]
1940 fn blocks_command_substitution_dollar_paren() {
1941 let err = Capability::execute(
1943 &ShellExec,
1944 &serde_json::json!({"cmd": "$(echo rm) -rf /"}),
1945 &Context {
1946 dry_run: false,
1947 job_id: "test".into(),
1948 working_dir: std::env::temp_dir(),
1949 },
1950 )
1951 .unwrap_err();
1952 assert!(
1953 format!("{}", err).contains("command substitution"),
1954 "Should block $( ) command substitution"
1955 );
1956 }
1957
1958 #[test]
1959 fn blocks_command_substitution_backtick() {
1960 let err = Capability::execute(
1962 &ShellExec,
1963 &serde_json::json!({"cmd": "`echo rm` -rf /"}),
1964 &Context {
1965 dry_run: false,
1966 job_id: "test".into(),
1967 working_dir: std::env::temp_dir(),
1968 },
1969 )
1970 .unwrap_err();
1971 assert!(
1972 format!("{}", err).contains("command substitution"),
1973 "Should block backtick command substitution"
1974 );
1975 }
1976
1977 #[test]
1978 fn blocks_command_substitution_in_double_quotes() {
1979 let err = Capability::execute(
1981 &ShellExec,
1982 &serde_json::json!({"cmd": "\"$(echo rm)\" -rf /"}),
1983 &Context {
1984 dry_run: false,
1985 job_id: "test".into(),
1986 working_dir: std::env::temp_dir(),
1987 },
1988 )
1989 .unwrap_err();
1990 assert!(
1991 format!("{}", err).contains("command substitution"),
1992 "Should block $( ) even inside double quotes"
1993 );
1994 }
1995
1996 #[test]
1999 fn detokenize_strips_double_quotes() {
2000 assert_eq!(detokenize_command("r\"m\" -rf /"), "rm -rf /");
2002 }
2003
2004 #[test]
2005 fn detokenize_strips_single_quotes() {
2006 assert_eq!(detokenize_command("'r''m' -rf /"), "rm -rf /");
2008 }
2009
2010 #[test]
2011 fn detokenize_strips_backslash() {
2012 assert_eq!(detokenize_command("r\\m -rf /"), "rm -rf /");
2014 }
2015
2016 #[test]
2017 fn detokenize_mixed_quotes() {
2018 assert_eq!(detokenize_command("r\"m\" -r\"f\""), "rm -rf");
2020 }
2021
2022 #[test]
2023 fn detokenize_preserves_non_quoted() {
2024 assert_eq!(detokenize_command("echo hello"), "echo hello");
2026 assert_eq!(detokenize_command("ls -la /tmp"), "ls -la /tmp");
2027 }
2028
2029 #[test]
2030 fn blocks_rm_via_double_quote_bypass() {
2031 let err = Capability::execute(
2033 &ShellExec,
2034 &serde_json::json!({"cmd": "r\"m\" -rf /"}),
2035 &Context {
2036 dry_run: false,
2037 job_id: "test".into(),
2038 working_dir: std::env::temp_dir(),
2039 },
2040 )
2041 .unwrap_err();
2042 let msg = format!("{}", err);
2043 assert!(
2044 msg.contains("dangerous command blocked") || msg.contains("recursive rm"),
2045 "Should block r\"m\" -rf /, got: {}",
2046 msg
2047 );
2048 }
2049
2050 #[test]
2051 fn blocks_rm_via_single_quote_bypass() {
2052 let err = Capability::execute(
2054 &ShellExec,
2055 &serde_json::json!({"cmd": "'r''m' -rf /"}),
2056 &Context {
2057 dry_run: false,
2058 job_id: "test".into(),
2059 working_dir: std::env::temp_dir(),
2060 },
2061 )
2062 .unwrap_err();
2063 assert!(
2064 format!("{}", err).contains("recursive rm")
2065 || format!("{}", err).contains("rm command blocked"),
2066 "Should block 'r''m' -rf /"
2067 );
2068 }
2069
2070 #[test]
2071 fn blocks_rm_via_backslash_bypass() {
2072 let err = Capability::execute(
2074 &ShellExec,
2075 &serde_json::json!({"cmd": "r\\m -rf /"}),
2076 &Context {
2077 dry_run: false,
2078 job_id: "test".into(),
2079 working_dir: std::env::temp_dir(),
2080 },
2081 )
2082 .unwrap_err();
2083 assert!(
2084 format!("{}", err).contains("recursive rm")
2085 || format!("{}", err).contains("rm command blocked"),
2086 "Should block r\\m -rf /"
2087 );
2088 }
2089
2090 #[test]
2091 fn blocks_dd_via_quote_bypass() {
2092 let err = Capability::execute(
2094 &ShellExec,
2095 &serde_json::json!({"cmd": "d\"d\" if=/dev/zero"}),
2096 &Context {
2097 dry_run: false,
2098 job_id: "test".into(),
2099 working_dir: std::env::temp_dir(),
2100 },
2101 )
2102 .unwrap_err();
2103 assert!(
2104 format!("{}", err).contains("dd"),
2105 "Should block d\"d\" (dd bypass)"
2106 );
2107 }
2108
2109 #[test]
2112 fn blocks_cat_outside_allowed() {
2113 let err = Capability::execute(
2115 &ShellExec,
2116 &serde_json::json!({"cmd": "cat /etc/passwd"}),
2117 &Context {
2118 dry_run: false,
2119 job_id: "test".into(),
2120 working_dir: std::env::temp_dir(),
2121 },
2122 )
2123 .unwrap_err();
2124 assert!(
2125 format!("{}", err).contains("outside allowed directories"),
2126 "Should block cat /etc/passwd"
2127 );
2128 }
2129
2130 #[test]
2131 fn blocks_ls_tilde_ssh() {
2132 let err = Capability::execute(
2138 &ShellExec,
2139 &serde_json::json!({"cmd": "ls ~/../../etc/passwd"}),
2140 &Context {
2141 dry_run: false,
2142 job_id: "test".into(),
2143 working_dir: std::env::temp_dir(),
2144 },
2145 )
2146 .unwrap_err();
2147 let msg = format!("{}", err);
2149 assert!(
2150 msg.contains("outside allowed") || msg.contains("traversal"),
2151 "Should block ls ~/../../etc/passwd, got: {}",
2152 msg
2153 );
2154 }
2155
2156 #[test]
2157 fn blocks_path_to_root() {
2158 let err = Capability::execute(
2160 &ShellExec,
2161 &serde_json::json!({"cmd": "cat /root/.bashrc"}),
2162 &Context {
2163 dry_run: false,
2164 job_id: "test".into(),
2165 working_dir: std::env::temp_dir(),
2166 },
2167 )
2168 .unwrap_err();
2169 assert!(
2170 format!("{}", err).contains("outside allowed directories"),
2171 "Should block cat /root/.bashrc"
2172 );
2173 }
2174
2175 #[test]
2176 fn allows_cat_in_tmp() {
2177 let r = Capability::execute(
2180 &ShellExec,
2181 &serde_json::json!({"cmd": "echo test > /tmp/runtimo_path_test.txt && cat /tmp/runtimo_path_test.txt"}),
2182 &Context {
2183 dry_run: false,
2184 job_id: "test".into(),
2185 working_dir: std::env::temp_dir(),
2186 },
2187 );
2188 let _ = std::fs::remove_file("/tmp/runtimo_path_test.txt");
2190 match r {
2191 Ok(o) => assert_eq!(o.status, "ok", "Should allow cat in /tmp"),
2192 Err(e) => {
2193 let msg = format!("{}", e);
2194 assert!(
2195 !msg.contains("outside allowed"),
2196 "Should NOT block /tmp path, got: {}",
2197 msg
2198 );
2199 }
2200 }
2201 }
2202
2203 #[test]
2204 fn allows_cat_in_home() {
2205 let home = std::env::var("HOME").unwrap_or_else(|_| "/home/user".to_string());
2207 let allowed = crate::config::RuntimoConfig::get_allowed_prefixes();
2208 let is_allowed = allowed
2209 .iter()
2210 .any(|p| home.starts_with(p) || p.starts_with(&home));
2211 if !is_allowed {
2212 eprintln!("SKIP: HOME ({}) is outside allowed prefixes; test requires HOME within allowed area", home);
2214 return;
2215 }
2216 let test_path = format!("{}/runtimo_home_path_test.txt", home);
2217 let cmd = format!("echo ok > {} && cat {}", test_path, test_path);
2218 let r = Capability::execute(
2219 &ShellExec,
2220 &serde_json::json!({"cmd": cmd}),
2221 &Context {
2222 dry_run: false,
2223 job_id: "test".into(),
2224 working_dir: std::env::temp_dir(),
2225 },
2226 );
2227 let _ = std::fs::remove_file(&test_path);
2228 match r {
2229 Ok(o) => assert_eq!(o.status, "ok", "Should allow cat in HOME"),
2230 Err(e) => {
2231 let msg = format!("{}", e);
2232 assert!(
2233 !msg.contains("outside allowed"),
2234 "Should NOT block HOME path, got: {}",
2235 msg
2236 );
2237 }
2238 }
2239 }
2240
2241 #[test]
2244 fn blocks_var_expanded_path_outside_allowed() {
2245 let err = Capability::execute(
2250 &ShellExec,
2251 &serde_json::json!({"cmd": "cat $HOME/../../etc/shadow"}),
2252 &Context {
2253 dry_run: false,
2254 job_id: "test".into(),
2255 working_dir: std::env::temp_dir(),
2256 },
2257 )
2258 .unwrap_err();
2259 let msg = format!("{}", err);
2260 assert!(
2261 msg.contains("outside allowed") || msg.contains("traversal"),
2262 "Should block $HOME/../../etc/shadow, got: {}",
2263 msg
2264 );
2265 }
2266
2267 #[test]
2268 fn blocks_var_brace_expanded_path() {
2269 let err = Capability::execute(
2271 &ShellExec,
2272 &serde_json::json!({"cmd": "cat ${HOME}/../../etc/shadow"}),
2273 &Context {
2274 dry_run: false,
2275 job_id: "test".into(),
2276 working_dir: std::env::temp_dir(),
2277 },
2278 )
2279 .unwrap_err();
2280 let msg = format!("{}", err);
2281 assert!(
2282 msg.contains("outside allowed") || msg.contains("traversal"),
2283 "Should block brace-syntax var expansion"
2284 );
2285 }
2286
2287 #[test]
2288 fn test_expand_shell_vars_resolves_home() {
2289 let expanded = expand_shell_vars("$HOME/.ssh");
2291 let home = std::env::var("HOME").unwrap_or_default();
2292 assert!(
2293 expanded.starts_with(&home),
2294 "Should expand $HOME, got: {}",
2295 expanded
2296 );
2297 assert!(
2298 expanded.ends_with("/.ssh"),
2299 "Should keep suffix, got: {}",
2300 expanded
2301 );
2302 }
2303
2304 #[test]
2305 fn test_expand_shell_vars_brace_syntax() {
2306 let expanded = expand_shell_vars("${HOME}/.ssh");
2307 let home = std::env::var("HOME").unwrap_or_default();
2308 assert!(
2309 expanded.starts_with(&home),
2310 "Should expand brace-syntax var"
2311 );
2312 }
2313
2314 #[test]
2317 fn blocks_redirect_to_outside_path() {
2318 let err = Capability::execute(
2320 &ShellExec,
2321 &serde_json::json!({"cmd": "echo evil >/etc/cron.d/backdoor"}),
2322 &Context {
2323 dry_run: false,
2324 job_id: "test".into(),
2325 working_dir: std::env::temp_dir(),
2326 },
2327 )
2328 .unwrap_err();
2329 assert!(
2330 format!("{}", err).contains("outside allowed directories"),
2331 "Should block redirect to /etc/cron.d/backdoor"
2332 );
2333 }
2334
2335 #[test]
2336 fn blocks_append_redirect_to_outside_path() {
2337 let err = Capability::execute(
2339 &ShellExec,
2340 &serde_json::json!({"cmd": "echo evil >>/etc/hosts"}),
2341 &Context {
2342 dry_run: false,
2343 job_id: "test".into(),
2344 working_dir: std::env::temp_dir(),
2345 },
2346 )
2347 .unwrap_err();
2348 assert!(
2349 format!("{}", err).contains("outside allowed directories"),
2350 "Should block append redirect to /etc/hosts"
2351 );
2352 }
2353
2354 #[test]
2355 fn blocks_stderr_redirect_outside() {
2356 let err = Capability::execute(
2358 &ShellExec,
2359 &serde_json::json!({"cmd": "ls 2>/etc/malicious"}),
2360 &Context {
2361 dry_run: false,
2362 job_id: "test".into(),
2363 working_dir: std::env::temp_dir(),
2364 },
2365 )
2366 .unwrap_err();
2367 assert!(
2368 format!("{}", err).contains("outside allowed directories"),
2369 "Should block 2> redirect to /etc/malicious"
2370 );
2371 }
2372
2373 #[test]
2374 fn allows_redirect_to_allowed_path() {
2375 let r = Capability::execute(
2377 &ShellExec,
2378 &serde_json::json!({"cmd": "echo hello >/tmp/runtimo_redirect_test.txt"}),
2379 &Context {
2380 dry_run: false,
2381 job_id: "test".into(),
2382 working_dir: std::env::temp_dir(),
2383 },
2384 );
2385 let _ = std::fs::remove_file("/tmp/runtimo_redirect_test.txt");
2386 match r {
2387 Ok(o) => assert_eq!(o.status, "ok"),
2388 Err(e) => {
2389 assert!(
2390 !format!("{}", e).contains("outside allowed"),
2391 "Should NOT block redirect to /tmp, got: {}",
2392 e
2393 );
2394 }
2395 }
2396 }
2397
2398 #[test]
2401 fn blocks_relative_parent_traversal() {
2402 let err = Capability::execute(
2404 &ShellExec,
2405 &serde_json::json!({"cmd": "cat ../../etc/passwd"}),
2406 &Context {
2407 dry_run: false,
2408 job_id: "test".into(),
2409 working_dir: std::env::temp_dir(),
2410 },
2411 )
2412 .unwrap_err();
2413 assert!(
2414 format!("{}", err).contains("outside allowed directories"),
2415 "Should block relative path traversal to /etc/passwd"
2416 );
2417 }
2418
2419 #[test]
2420 fn blocks_deep_relative_traversal() {
2421 let err = Capability::execute(
2423 &ShellExec,
2424 &serde_json::json!({"cmd": "cat ./../../../etc/shadow"}),
2425 &Context {
2426 dry_run: false,
2427 job_id: "test".into(),
2428 working_dir: std::env::temp_dir(),
2429 },
2430 )
2431 .unwrap_err();
2432 assert!(
2433 format!("{}", err).contains("outside allowed directories"),
2434 "Should block deep relative traversal"
2435 );
2436 }
2437
2438 #[test]
2439 fn allows_relative_within_allowed() {
2440 let test_file = "/tmp/runtimo_relative_allowed_test.txt";
2445 let r = Capability::execute(
2446 &ShellExec,
2447 &serde_json::json!({"cmd": format!("echo ok > {}", test_file)}),
2448 &Context {
2449 dry_run: false,
2450 job_id: "test".into(),
2451 working_dir: std::env::temp_dir(),
2452 },
2453 );
2454 let _ = std::fs::remove_file(test_file);
2455 match r {
2456 Ok(o) => assert_eq!(o.status, "ok", "Should allow path within /tmp"),
2457 Err(e) => {
2458 let msg = format!("{}", e);
2459 assert!(
2460 !msg.contains("outside allowed"),
2461 "Should NOT block /tmp path, got: {}",
2462 msg
2463 );
2464 }
2465 }
2466 }
2467
2468 #[test]
2471 fn blocks_env_command() {
2472 let err = Capability::execute(
2473 &ShellExec,
2474 &serde_json::json!({"cmd": "env"}),
2475 &Context {
2476 dry_run: false,
2477 job_id: "test".into(),
2478 working_dir: std::env::temp_dir(),
2479 },
2480 )
2481 .unwrap_err();
2482 assert!(
2483 format!("{}", err).contains("environment variable dumping"),
2484 "Should block `env` command"
2485 );
2486 }
2487
2488 #[test]
2489 fn blocks_printenv_command() {
2490 let err = Capability::execute(
2491 &ShellExec,
2492 &serde_json::json!({"cmd": "printenv"}),
2493 &Context {
2494 dry_run: false,
2495 job_id: "test".into(),
2496 working_dir: std::env::temp_dir(),
2497 },
2498 )
2499 .unwrap_err();
2500 assert!(
2501 format!("{}", err).contains("environment variable dumping"),
2502 "Should block `printenv` command"
2503 );
2504 }
2505
2506 #[test]
2507 fn blocks_set_command() {
2508 let err = Capability::execute(
2509 &ShellExec,
2510 &serde_json::json!({"cmd": "set"}),
2511 &Context {
2512 dry_run: false,
2513 job_id: "test".into(),
2514 working_dir: std::env::temp_dir(),
2515 },
2516 )
2517 .unwrap_err();
2518 assert!(
2519 format!("{}", err).contains("environment variable dumping"),
2520 "Should block `set` command"
2521 );
2522 }
2523
2524 #[test]
2525 fn blocks_export_command() {
2526 let err = Capability::execute(
2527 &ShellExec,
2528 &serde_json::json!({"cmd": "export"}),
2529 &Context {
2530 dry_run: false,
2531 job_id: "test".into(),
2532 working_dir: std::env::temp_dir(),
2533 },
2534 )
2535 .unwrap_err();
2536 assert!(
2537 format!("{}", err).contains("environment variable dumping"),
2538 "Should block `export` command"
2539 );
2540 }
2541
2542 #[test]
2545 fn blocks_export_with_assignment() {
2546 let err = Capability::execute(
2549 &ShellExec,
2550 &serde_json::json!({"cmd": "export FOO=bar"}),
2551 &Context {
2552 dry_run: false,
2553 job_id: "test".into(),
2554 working_dir: std::env::temp_dir(),
2555 },
2556 )
2557 .unwrap_err();
2558 assert!(
2559 format!("{}", err).contains("environment variable dumping"),
2560 "Should block `export FOO=bar` (export with assignment)"
2561 );
2562 }
2563
2564 #[test]
2565 fn blocks_declare_p_command() {
2566 let err = Capability::execute(
2567 &ShellExec,
2568 &serde_json::json!({"cmd": "declare -p"}),
2569 &Context {
2570 dry_run: false,
2571 job_id: "test".into(),
2572 working_dir: std::env::temp_dir(),
2573 },
2574 )
2575 .unwrap_err();
2576 assert!(
2577 format!("{}", err).contains("environment variable dumping"),
2578 "Should block `declare -p` command"
2579 );
2580 }
2581
2582 #[test]
2583 fn blocks_env_via_quote_bypass() {
2584 let err = Capability::execute(
2586 &ShellExec,
2587 &serde_json::json!({"cmd": "e\"n\"v"}),
2588 &Context {
2589 dry_run: false,
2590 job_id: "test".into(),
2591 working_dir: std::env::temp_dir(),
2592 },
2593 )
2594 .unwrap_err();
2595 assert!(
2596 format!("{}", err).contains("environment variable dumping"),
2597 "Should block e\"n\"v (quoted env bypass)"
2598 );
2599 }
2600
2601 #[test]
2602 fn allows_harmless_command_with_env_check() {
2603 let r = Capability::execute(
2605 &ShellExec,
2606 &serde_json::json!({"cmd": "echo hello"}),
2607 &Context {
2608 dry_run: false,
2609 job_id: "test".into(),
2610 working_dir: std::env::temp_dir(),
2611 },
2612 )
2613 .unwrap();
2614 assert_eq!(r.status, "ok");
2615 assert!(r.data.as_ref().unwrap()["stdout"]
2616 .as_str()
2617 .unwrap()
2618 .contains("hello"));
2619 }
2620
2621 #[test]
2622 fn is_sensitive_env_var_detects_aws() {
2623 assert!(is_sensitive_env_var("AWS_ACCESS_KEY_ID"));
2624 assert!(is_sensitive_env_var("AWS_SECRET_ACCESS_KEY"));
2625 assert!(is_sensitive_env_var("aws_session_token")); }
2627
2628 #[test]
2629 fn is_sensitive_env_var_detects_suffixes() {
2630 assert!(is_sensitive_env_var("MYAPP_API_KEY"));
2631 assert!(is_sensitive_env_var("GITHUB_TOKEN"));
2632 assert!(is_sensitive_env_var("DB_PASSWORD"));
2633 assert!(is_sensitive_env_var("STRIPE_SECRET_KEY"));
2634 }
2635
2636 #[test]
2637 fn is_sensitive_env_var_allows_safe() {
2638 assert!(!is_sensitive_env_var("HOME"));
2639 assert!(!is_sensitive_env_var("USER"));
2640 assert!(!is_sensitive_env_var("PATH"));
2641 assert!(!is_sensitive_env_var("TERM"));
2642 assert!(!is_sensitive_env_var("LANG"));
2643 assert!(!is_sensitive_env_var("RUNTIMO_ENABLE_NETWORK"));
2644 }
2645
2646 #[test]
2649 fn is_sensitive_env_var_allows_known_non_secret_suffix() {
2650 assert!(!is_sensitive_env_var("FOREIGN_KEY"));
2652 assert!(!is_sensitive_env_var("PRIMARY_KEY"));
2653 assert!(!is_sensitive_env_var("PUBLIC_KEY"));
2654 assert!(!is_sensitive_env_var("BASE_URL"));
2655 }
2656
2657 #[test]
2660 fn is_sensitive_env_var_detects_ld_preload() {
2661 assert!(is_sensitive_env_var("LD_PRELOAD"));
2663 assert!(is_sensitive_env_var("LD_LIBRARY_PATH"));
2664 assert!(is_sensitive_env_var("LD_DEBUG"));
2665 assert!(is_sensitive_env_var("LD_BIND_NOW"));
2666 }
2667
2668 #[test]
2669 fn is_sensitive_env_var_detects_dyld() {
2670 assert!(is_sensitive_env_var("DYLD_INSERT_LIBRARIES"));
2672 assert!(is_sensitive_env_var("DYLD_LIBRARY_PATH"));
2673 }
2674
2675 #[test]
2676 fn sanitized_env_strips_secrets() {
2677 std::env::set_var("RUNTIMO_TEST_SECRET_KEY", "test-value");
2679 let env = sanitized_env();
2680 std::env::remove_var("RUNTIMO_TEST_SECRET_KEY");
2681
2682 assert!(
2683 !env.iter()
2684 .map(|(k, _)| k.as_str())
2685 .any(|x| x == "RUNTIMO_TEST_SECRET_KEY"),
2686 "RUNTIMO_TEST_SECRET_KEY should be stripped from env"
2687 );
2688 }
2689
2690 #[test]
2691 fn sanitized_env_preserves_safe() {
2692 let env = sanitized_env();
2693 let keys: Vec<&str> = env.iter().map(|(k, _)| k.as_str()).collect();
2694 assert!(keys.contains(&"HOME"), "HOME should be preserved");
2695 assert!(keys.contains(&"USER"), "USER should be preserved");
2696 }
2697}