runtimo_core/capabilities/
shell_exec.rs1use crate::capability::{Capability, Context, Output};
62use crate::validation::path::{validate_path, PathContext};
63use crate::{Error, Result};
64use serde::{Deserialize, Serialize};
65use serde_json::Value;
66use std::fs;
67use std::io::{Read, Write};
68use std::os::unix::process::CommandExt;
69use std::process::{Child, Command, ExitStatus};
70use std::thread;
71use std::time::{Duration, Instant};
72
73type WaitResult = Result<(ExitStatus, Vec<u8>, Vec<u8>, Vec<u32>)>;
74
75const DEFAULT_TIMEOUT_SECS: u64 = 30;
76const MAX_OUTPUT_BYTES: usize = 10 * 1024 * 1024;
77const MAX_STDIN_BYTES: usize = 1024 * 1024;
78
79#[derive(Debug, Clone, Serialize, Deserialize)]
84pub struct ShellExecArgs {
85 #[serde(alias = "command")]
87 pub cmd: String,
88 pub timeout_secs: Option<u64>,
90 pub cwd: Option<String>,
92 pub stdin: Option<String>,
94}
95
96fn command_matches(cmd_lower: &str, names: &[&str]) -> bool {
101 let first_token = cmd_lower.split_whitespace().next().unwrap_or("");
102 for part in cmd_lower.split(['|', '&', ';']) {
104 let t = part.trim();
105 if names
106 .iter()
107 .any(|n| t == *n || t.starts_with(&format!("{} ", n)))
108 {
109 return true;
110 }
111 }
112 names.contains(&first_token)
113}
114
115fn is_dangerous_command(cmd: &str) -> Option<&'static str> {
116 let cmd_lower = cmd.to_lowercase();
117 if cmd_lower.contains("mkfs") || cmd_lower.contains("mkswap") {
118 return Some("filesystem creation commands are blocked");
119 }
120 if cmd_lower.contains("fdisk") || cmd_lower.contains("parted") {
121 return Some("disk partitioning commands are blocked");
122 }
123 if cmd_lower.contains(" dd ") || cmd_lower.starts_with("dd ") || cmd_lower.contains(" dd") {
124 return Some("dd (disk destroyer) is blocked");
125 }
126 if cmd_lower.contains("shutdown")
127 || cmd_lower.contains("reboot")
128 || cmd_lower.contains("poweroff")
129 {
130 return Some("system power commands are blocked");
131 }
132 if command_matches(&cmd_lower, &["chown", "chgrp"]) {
134 return Some("ownership change commands are blocked");
135 }
136 if command_matches(&cmd_lower, &["mount", "umount"]) {
138 return Some("mount/unmount commands are blocked");
139 }
140 if command_matches(&cmd_lower, &["iptables", "nft"]) {
142 return Some("firewall manipulation commands are blocked");
143 }
144 if cmd_lower.contains("rm")
145 && (cmd_lower.contains("-rf")
146 || cmd_lower.contains("-fr")
147 || cmd_lower.contains("--recursive")
148 || cmd_lower.contains(" -r ")
149 || cmd_lower.contains(" -f "))
150 && (cmd_lower.contains(" / ")
151 || cmd_lower.contains("/*")
152 || cmd_lower.contains("/dev")
153 || cmd_lower.contains("/boot")
154 || cmd_lower.contains("/home")
155 || cmd_lower.contains("/etc")
156 || cmd_lower.contains("/usr")
157 || cmd_lower.contains("/var")
158 || cmd_lower.contains("/lib")
159 || cmd_lower.contains("/opt")
160 || cmd_lower.contains("/bin")
161 || cmd_lower.contains("/sbin"))
162 {
163 return Some("rm -rf / --recursive on system directories is blocked");
164 }
165 if cmd_lower.contains("rm")
166 && (cmd_lower.contains("-rf")
167 || cmd_lower.contains("-fr")
168 || cmd_lower.contains("--recursive")
169 || cmd_lower.contains(" -r ")
170 || cmd_lower.contains(" -f "))
171 && cmd_lower.contains('~')
172 {
173 return Some("rm with shell expansions is blocked — use explicit paths");
174 }
175 if cmd_lower.contains("chmod") && cmd_lower.contains("777") && cmd_lower.contains(" /") {
176 return Some("chmod 777 / is blocked");
177 }
178 None
179}
180
181fn is_network_command(cmd: &str) -> bool {
188 let cmd_lower = cmd.to_lowercase();
189 command_matches(
190 &cmd_lower,
191 &[
192 "curl", "wget", "nc", "ncat", "netcat", "socat", "ssh", "scp", "telnet",
193 ],
194 )
195}
196
197fn network_enabled() -> bool {
201 std::env::var("RUNTIMO_ENABLE_NETWORK").as_deref() == Ok("1")
202}
203
204#[allow(clippy::arithmetic_side_effects)] fn wait_with_timeout(child: &mut Child, pgid: u32, timeout_secs: u64) -> WaitResult {
206 let start = Instant::now();
207 let timeout = Duration::from_secs(timeout_secs);
208 let child_pid = child.id();
209 let stdout_thread = child.stdout.take().map(|stdout| {
210 thread::spawn(move || {
211 let mut data = Vec::new();
212 let _ = stdout.take(MAX_OUTPUT_BYTES as u64).read_to_end(&mut data);
213 data
214 })
215 });
216 let stderr_thread = child.stderr.take().map(|stderr| {
217 thread::spawn(move || {
218 let mut data = Vec::new();
219 let _ = stderr.take(MAX_OUTPUT_BYTES as u64).read_to_end(&mut data);
220 data
221 })
222 });
223 let mut last_descendants: Vec<u32>;
224 loop {
225 if start.elapsed() > timeout {
226 #[allow(clippy::cast_possible_wrap)]
229 unsafe {
230 let _ = libc::kill(-(pgid as libc::pid_t), libc::SIGKILL);
231 }
232 let killed_descendants = get_all_descendants(child_pid);
233 let _ = child.wait();
234 let _ = stdout_thread.map(|h| h.join().unwrap_or_default());
235 let _ = stderr_thread.map(|h| h.join().unwrap_or_default());
236 return Err(Error::ExecutionFailed(format!(
237 "command timed out after {}s (killed {} descendants)",
238 timeout_secs,
239 killed_descendants.len()
240 )));
241 }
242 last_descendants = get_all_descendants(child_pid);
243 match child.try_wait() {
244 Ok(Some(status)) => {
245 let stdout_data = stdout_thread
246 .map(|h| h.join().unwrap_or_default())
247 .unwrap_or_default();
248 let stderr_data = stderr_thread
249 .map(|h| h.join().unwrap_or_default())
250 .unwrap_or_default();
251 return Ok((status, stdout_data, stderr_data, last_descendants));
252 }
253 Ok(None) => std::thread::sleep(Duration::from_millis(50)),
254 Err(e) => return Err(Error::ExecutionFailed(format!("error waiting: {}", e))),
255 }
256 }
257}
258
259fn get_direct_children(pid: u32) -> Vec<u32> {
260 let children_path = format!("/proc/{}/children", pid);
261 if let Ok(content) = fs::read_to_string(&children_path) {
262 content
263 .split_whitespace()
264 .filter_map(|s| s.parse::<u32>().ok())
265 .collect()
266 } else {
267 Vec::new()
268 }
269}
270
271fn get_all_descendants(pid: u32) -> Vec<u32> {
272 let mut descendants = Vec::new();
273 let mut stack = vec![pid];
274 let mut visited = std::collections::HashSet::new();
275 while let Some(current) = stack.pop() {
276 if visited.contains(¤t) {
277 continue;
278 }
279 visited.insert(current);
280 let children = get_direct_children(current);
281 if children.is_empty() {
282 if let Ok(output) = std::process::Command::new("pgrep")
283 .arg("-P")
284 .arg(current.to_string())
285 .output()
286 {
287 if output.status.success() {
288 let pgrep_lines = String::from_utf8_lossy(&output.stdout).to_string();
289 let pgrep_children = pgrep_lines
290 .lines()
291 .filter_map(|s| s.trim().parse::<u32>().ok());
292 for child in pgrep_children {
293 if !visited.contains(&child) {
294 descendants.push(child);
295 stack.push(child);
296 }
297 }
298 continue;
299 }
300 }
301 }
302 for child in children {
303 if !visited.contains(&child) {
304 descendants.push(child);
305 stack.push(child);
306 }
307 }
308 }
309 descendants
310}
311
312#[allow(clippy::exhaustive_structs)]
318pub struct ShellExec;
319
320impl Capability for ShellExec {
321 fn name(&self) -> &'static str {
322 "ShellExec"
323 }
324 fn description(&self) -> &'static str {
325 "exec cmd via sh -c, timeout, audit. Dangerous cmds: mkfs,fdisk,dd,shutdown,rm -rf / blocked."
326 }
327 fn schema(&self) -> Value {
328 serde_json::json!({
329 "type": "object",
330 "properties": {
331 "cmd": { "type": "string", "description": "Command to execute via sh -c" },
332 "timeout_secs": { "type": "integer", "minimum": 1, "maximum": 300 },
333 "cwd": { "type": "string" },
334 "stdin": { "type": "string" }
335 },
336 "required": ["cmd"]
337 })
338 }
339 fn validate(&self, args: &Value) -> Result<()> {
340 let args: ShellExecArgs = serde_json::from_value(args.clone())
341 .map_err(|e| Error::SchemaValidationFailed(e.to_string()))?;
342 if args.cmd.is_empty() {
343 return Err(Error::SchemaValidationFailed("cmd is empty".into()));
344 }
345 Ok(())
346 }
347 fn execute(&self, args: &Value, ctx: &Context) -> Result<Output> {
348 if ctx.dry_run {
349 return Ok(Output {
350 success: true,
351 data: serde_json::json!({ "cmd": args.get("cmd").and_then(|v| v.as_str()).unwrap_or(""), "dry_run": true }),
352 message: Some("DRY RUN".into()),
353 });
354 }
355 let args: ShellExecArgs = serde_json::from_value(args.clone())
356 .map_err(|e| Error::ExecutionFailed(e.to_string()))?;
357 let timeout = args.timeout_secs.unwrap_or(DEFAULT_TIMEOUT_SECS);
358 if let Some(reason) = is_dangerous_command(&args.cmd) {
359 return Err(Error::ExecutionFailed(format!(
360 "dangerous command blocked: {}",
361 reason
362 )));
363 }
364 if !network_enabled() && is_network_command(&args.cmd) {
365 return Err(Error::ExecutionFailed(
366 "network commands blocked — set RUNTIMO_ENABLE_NETWORK=1 to enable".into(),
367 ));
368 }
369 let mut cmd = Command::new("sh");
370 cmd.env("PATH", "/usr/local/bin:/usr/bin:/bin");
375 cmd.arg("-c").arg(&args.cmd);
376 if let Some(cwd) = &args.cwd {
377 let path_ctx = PathContext {
378 require_exists: true,
379 require_file: false,
380 ..Default::default()
381 };
382 let cwd_path = validate_path(cwd, &path_ctx)
383 .map_err(|e| Error::ExecutionFailed(format!("invalid cwd: {}", e)))?;
384 cmd.current_dir(cwd_path);
385 }
386 let mut child = cmd
387 .process_group(0)
388 .stdout(std::process::Stdio::piped())
389 .stderr(std::process::Stdio::piped())
390 .stdin(if args.stdin.is_some() {
391 std::process::Stdio::piped()
392 } else {
393 std::process::Stdio::null()
394 })
395 .spawn()
396 .map_err(|e| Error::ExecutionFailed(format!("failed to spawn: {}", e)))?;
397 let child_pid = child.id();
398 let pgid = child_pid;
399 if let Some(ref stdin_content) = args.stdin {
400 if stdin_content.len() > MAX_STDIN_BYTES {
401 return Err(Error::ExecutionFailed("stdin too large".into()));
402 }
403 if let Some(mut stdin_pipe) = child.stdin.take() {
404 let _ = stdin_pipe.write_all(stdin_content.as_bytes());
405 }
406 }
407 let (exit_status, stdout, stderr, descendants) =
408 wait_with_timeout(&mut child, pgid, timeout)?;
409 let mut spawned_pids = vec![child_pid];
410 spawned_pids.extend(descendants);
411 let stdout_str = String::from_utf8_lossy(&stdout).to_string();
412 let stderr_str = String::from_utf8_lossy(&stderr).to_string();
413 let success = exit_status.success();
414
415 Ok(Output {
416 success,
417 data: serde_json::json!({ "cmd": &args.cmd, "stdout": stdout_str, "stderr": stderr_str, "exit_code": exit_status.code().unwrap_or(-1), "pid": child_pid, "spawned_pids": spawned_pids, "timeout_secs": timeout, "timed_out": exit_status.code().is_none(), "truncated": stdout.len() >= MAX_OUTPUT_BYTES || stderr.len() >= MAX_OUTPUT_BYTES }),
418 message: if success {
419 Some("completed".into())
420 } else {
421 Some(format!("exit code {}", exit_status.code().unwrap_or(-1)))
422 },
423 })
424 }
425}
426
427#[cfg(test)]
428mod tests {
429 use super::*;
430 use crate::capability::Capability;
431 use std::time::Instant;
432 #[test]
433 fn executes_uptime() {
434 let r = ShellExec
435 .execute(
436 &serde_json::json!({"cmd": "uptime"}),
437 &Context {
438 dry_run: false,
439 job_id: "test".into(),
440 working_dir: std::env::temp_dir(),
441 },
442 )
443 .unwrap();
444 assert!(r.success);
445 }
446 #[test]
447 fn pipes_work() {
448 let r = ShellExec
449 .execute(
450 &serde_json::json!({"cmd": "echo hi | cat"}),
451 &Context {
452 dry_run: false,
453 job_id: "test".into(),
454 working_dir: std::env::temp_dir(),
455 },
456 )
457 .unwrap();
458 assert!(r.success);
459 assert!(r.data["stdout"].as_str().unwrap().contains("hi"));
460 }
461 #[test]
462 fn chaining_works() {
463 let r = ShellExec
464 .execute(
465 &serde_json::json!({"cmd": "echo a && echo b"}),
466 &Context {
467 dry_run: false,
468 job_id: "test".into(),
469 working_dir: std::env::temp_dir(),
470 },
471 )
472 .unwrap();
473 assert!(r.success);
474 }
475 #[test]
476 fn blocks_dangerous() {
477 assert!(ShellExec
478 .execute(
479 &serde_json::json!({"cmd": "mkfs"}),
480 &Context {
481 dry_run: false,
482 job_id: "test".into(),
483 working_dir: std::env::temp_dir()
484 }
485 )
486 .is_err());
487 }
488 #[test]
489 fn blocks_recursive_flag() {
490 assert!(ShellExec
492 .execute(
493 &serde_json::json!({"cmd": "rm --recursive /home"}),
494 &Context {
495 dry_run: false,
496 job_id: "test".into(),
497 working_dir: std::env::temp_dir()
498 }
499 )
500 .is_err());
501 }
502 #[test]
503 fn blocks_ownership_commands() {
504 for cmd in &["chown root /tmp/x", "chgrp staff /tmp/x"] {
505 assert!(
506 ShellExec
507 .execute(
508 &serde_json::json!({"cmd": cmd}),
509 &Context {
510 dry_run: false,
511 job_id: "test".into(),
512 working_dir: std::env::temp_dir()
513 }
514 )
515 .is_err(),
516 "should block: {}",
517 cmd
518 );
519 }
520 }
521 #[test]
522 fn blocks_mount_commands() {
523 for cmd in &["mount /dev/sda1 /mnt", "umount /mnt"] {
524 assert!(
525 ShellExec
526 .execute(
527 &serde_json::json!({"cmd": cmd}),
528 &Context {
529 dry_run: false,
530 job_id: "test".into(),
531 working_dir: std::env::temp_dir()
532 }
533 )
534 .is_err(),
535 "should block: {}",
536 cmd
537 );
538 }
539 }
540 #[test]
541 fn blocks_firewall_commands() {
542 for cmd in &["iptables -L", "nft list ruleset"] {
543 assert!(
544 ShellExec
545 .execute(
546 &serde_json::json!({"cmd": cmd}),
547 &Context {
548 dry_run: false,
549 job_id: "test".into(),
550 working_dir: std::env::temp_dir()
551 }
552 )
553 .is_err(),
554 "should block: {}",
555 cmd
556 );
557 }
558 }
559 #[test]
560 fn blocks_network_commands_by_default() {
561 std::env::remove_var("RUNTIMO_ENABLE_NETWORK");
563 for cmd in &[
564 "curl http://example.com",
565 "wget http://example.com",
566 "nc example.com 80",
567 ] {
568 assert!(
569 ShellExec
570 .execute(
571 &serde_json::json!({"cmd": cmd}),
572 &Context {
573 dry_run: false,
574 job_id: "test".into(),
575 working_dir: std::env::temp_dir()
576 }
577 )
578 .is_err(),
579 "should block network cmd: {}",
580 cmd
581 );
582 }
583 }
584 #[test]
585 fn allows_network_commands_when_enabled() {
586 std::env::set_var("RUNTIMO_ENABLE_NETWORK", "1");
587 let r = ShellExec.execute(
589 &serde_json::json!({"cmd": "curl --version"}),
590 &Context {
591 dry_run: false,
592 job_id: "test".into(),
593 working_dir: std::env::temp_dir(),
594 },
595 );
596 std::env::remove_var("RUNTIMO_ENABLE_NETWORK");
597 match r {
599 Ok(o) => assert!(o.success, "curl --version should succeed when enabled"),
600 Err(e) => {
601 let msg = e.to_string();
602 assert!(
603 !msg.contains("network commands blocked"),
604 "should NOT block network when RUNTIMO_ENABLE_NETWORK=1, got: {}",
605 msg
606 );
607 }
608 }
609 }
610 #[test]
611 fn enforces_timeout() {
612 let s = Instant::now();
613 assert!(ShellExec
614 .execute(
615 &serde_json::json!({"cmd": "sleep 5", "timeout_secs": 1}),
616 &Context {
617 dry_run: false,
618 job_id: "test".into(),
619 working_dir: std::env::temp_dir()
620 }
621 )
622 .is_err());
623 assert!(s.elapsed().as_secs() < 3);
624 }
625}