Skip to main content

runtimo_core/capabilities/
shell_exec.rs

1//! ShellExec capability — execute shell commands with full telemetry and audit trail.
2//!
3//! All commands execute via `sh -c`, providing full shell functionality:
4//! - Pipes: `ls | head -5`
5//! - Redirects: `echo hello > /tmp/file.txt`
6//! - Chaining: `echo first && echo second`
7//!
8//! # Guardrails (not security)
9//!
10//! **Threat model:** Agents making mistakes, not attackers.
11//! The blocklist catches obvious agent hallucinations/bugs.
12//!
13//! **What's blocked:**
14//! - Filesystem destruction: `rm -rf /`, `mkfs.*`, `dd if=/dev/zero`
15//! - System commands: `shutdown`, `reboot`, `poweroff`
16//! - Disk operations: `fdisk`, `parted`
17//!
18//! **What protects you:**
19//! - Dangerous command blocklist
20//! - Resource limits (timeout, process isolation)
21//! - WAL audit trail (enables undo/recovery)
22//!
23//! # Features
24//!
25//! - Timeout enforcement (default 30s, configurable)
26//! - Output capture (stdout/stderr, bounded to 10MB)
27//! - PID tracking (child + grandchildren via /proc/{pid}/children)
28//! - Process group isolation (kills all descendants on timeout)
29//! - Telemetry before/after execution
30//! - WAL logging for audit trail
31//! - Stdin pipe support
32//!
33//! # Example
34//!
35//! ```rust,ignore
36//! use runtimo_core::capabilities::ShellExec;
37//! use runtimo_core::capability::{Capability, Context};
38//! use serde_json::json;
39//!
40//! let result = ShellExec.execute(
41//!     &json!({"cmd": "ls | head -5", "timeout_secs": 10}),
42//!     &Context { dry_run: false, job_id: "test".into(), working_dir: std::env::temp_dir() }
43//! ).unwrap();
44//! ```
45
46use crate::capability::{Capability, Context, Output};
47use crate::validation::path::{validate_path, PathContext};
48use crate::{Error, Result};
49use serde::{Deserialize, Serialize};
50use serde_json::Value;
51use std::fs;
52use std::io::{Read, Write};
53use std::os::unix::process::CommandExt;
54use std::process::{Child, Command, ExitStatus};
55use std::thread;
56use std::time::{Duration, Instant};
57
58type WaitResult = Result<(ExitStatus, Vec<u8>, Vec<u8>, Vec<u32>)>;
59
60const DEFAULT_TIMEOUT_SECS: u64 = 30;
61const MAX_OUTPUT_BYTES: usize = 10 * 1024 * 1024;
62const MAX_STDIN_BYTES: usize = 1024 * 1024;
63
64#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct ShellExecArgs {
66    pub cmd: String,
67    pub timeout_secs: Option<u64>,
68    pub cwd: Option<String>,
69    pub stdin: Option<String>,
70}
71
72fn is_dangerous_command(cmd: &str) -> Option<&'static str> {
73    let cmd_lower = cmd.to_lowercase();
74    if cmd_lower.contains("mkfs") || cmd_lower.contains("mkswap") {
75        return Some("filesystem creation commands are blocked");
76    }
77    if cmd_lower.contains("fdisk") || cmd_lower.contains("parted") {
78        return Some("disk partitioning commands are blocked");
79    }
80    if cmd_lower.contains(" dd ") || cmd_lower.starts_with("dd ") || cmd_lower.contains(" dd") {
81        return Some("dd (disk destroyer) is blocked");
82    }
83    if cmd_lower.contains("shutdown") || cmd_lower.contains("reboot") || cmd_lower.contains("poweroff") {
84        return Some("system power commands are blocked");
85    }
86    if cmd_lower.contains("rm")
87        && (cmd_lower.contains("-rf") || cmd_lower.contains("-fr") || cmd_lower.contains(" -r ") || cmd_lower.contains(" -f "))
88        && (cmd_lower.contains(" / ") || cmd_lower.contains("/*") || cmd_lower.contains("/dev") || cmd_lower.contains("/boot"))
89    {
90        return Some("rm -rf on root, devices, or boot is blocked");
91    }
92    if cmd_lower.contains("chmod") && cmd_lower.contains("777") && cmd_lower.contains(" /") {
93        return Some("chmod 777 / is blocked");
94    }
95    None
96}
97
98fn wait_with_timeout(child: &mut Child, pgid: u32, timeout_secs: u64) -> WaitResult {
99    let start = Instant::now();
100    let timeout = Duration::from_secs(timeout_secs);
101    let child_pid = child.id();
102    let stdout_thread = child.stdout.take().map(|stdout| thread::spawn(move || {
103        let mut data = Vec::new();
104        let _ = stdout.take(MAX_OUTPUT_BYTES as u64).read_to_end(&mut data);
105        data
106    }));
107    let stderr_thread = child.stderr.take().map(|stderr| thread::spawn(move || {
108        let mut data = Vec::new();
109        let _ = stderr.take(MAX_OUTPUT_BYTES as u64).read_to_end(&mut data);
110        data
111    }));
112        let mut last_descendants: Vec<u32>;
113        loop {
114            if start.elapsed() > timeout {
115                unsafe { let _ = libc::kill(-(pgid as libc::pid_t), libc::SIGKILL); }
116                let killed_descendants = get_all_descendants(child_pid);
117                let _ = child.wait();
118                let _ = stdout_thread.map(|h| h.join().unwrap_or_default());
119                let _ = stderr_thread.map(|h| h.join().unwrap_or_default());
120                return Err(Error::ExecutionFailed(format!(
121                    "command timed out after {}s (killed {} descendants)",
122                    timeout_secs, killed_descendants.len()
123                )));
124            }
125            last_descendants = get_all_descendants(child_pid);
126            match child.try_wait() {
127                Ok(Some(status)) => {
128                    let stdout_data = stdout_thread.map(|h| h.join().unwrap_or_default()).unwrap_or_default();
129                    let stderr_data = stderr_thread.map(|h| h.join().unwrap_or_default()).unwrap_or_default();
130                    return Ok((status, stdout_data, stderr_data, last_descendants));
131                }
132                Ok(None) => std::thread::sleep(Duration::from_millis(50)),
133                Err(e) => return Err(Error::ExecutionFailed(format!("error waiting: {}", e))),
134            }
135        }
136}
137
138fn get_direct_children(pid: u32) -> Vec<u32> {
139    let children_path = format!("/proc/{}/children", pid);
140    if let Ok(content) = fs::read_to_string(&children_path) {
141        content.split_whitespace().filter_map(|s| s.parse::<u32>().ok()).collect()
142    } else { Vec::new() }
143}
144
145fn get_all_descendants(pid: u32) -> Vec<u32> {
146    let mut descendants = Vec::new();
147    let mut stack = vec![pid];
148    let mut visited = std::collections::HashSet::new();
149    while let Some(current) = stack.pop() {
150        if visited.contains(&current) { continue; }
151        visited.insert(current);
152        let children = get_direct_children(current);
153        if children.is_empty() {
154            if let Ok(output) = std::process::Command::new("pgrep").arg("-P").arg(current.to_string()).output() {
155                if output.status.success() {
156                    let pgrep_lines = String::from_utf8_lossy(&output.stdout).to_string(); let pgrep_children = pgrep_lines.lines().filter_map(|s| s.trim().parse::<u32>().ok());
157                    for child in pgrep_children {
158                        if !visited.contains(&child) { descendants.push(child); stack.push(child); }
159                    }
160                    continue;
161                }
162            }
163        }
164        for child in children {
165            if !visited.contains(&child) { descendants.push(child); stack.push(child); }
166        }
167    }
168    descendants
169}
170
171pub struct ShellExec;
172
173impl Capability for ShellExec {
174    fn name(&self) -> &'static str { "ShellExec" }
175    fn description(&self) -> &'static str { "exec cmd via sh -c, timeout, audit. Dangerous cmds: mkfs,fdisk,dd,shutdown,rm -rf / blocked." }
176    fn schema(&self) -> Value {
177        serde_json::json!({
178            "type": "object",
179            "properties": {
180                "cmd": { "type": "string", "description": "Command to execute via sh -c" },
181                "timeout_secs": { "type": "integer", "minimum": 1, "maximum": 300 },
182                "cwd": { "type": "string" },
183                "stdin": { "type": "string" }
184            },
185            "required": ["cmd"]
186        })
187    }
188    fn validate(&self, args: &Value) -> Result<()> {
189        let args: ShellExecArgs = serde_json::from_value(args.clone()).map_err(|e| Error::SchemaValidationFailed(e.to_string()))?;
190        if args.cmd.is_empty() { return Err(Error::SchemaValidationFailed("cmd is empty".into())); }
191        Ok(())
192    }
193    fn execute(&self, args: &Value, ctx: &Context) -> Result<Output> {
194        if ctx.dry_run {
195            return Ok(Output { success: true, data: serde_json::json!({ "cmd": args.get("cmd").and_then(|v| v.as_str()).unwrap_or(""), "dry_run": true }), message: Some("DRY RUN".into()) });
196        }
197        let args: ShellExecArgs = serde_json::from_value(args.clone()).map_err(|e| Error::ExecutionFailed(e.to_string()))?;
198        let timeout = args.timeout_secs.unwrap_or(DEFAULT_TIMEOUT_SECS);
199        if let Some(reason) = is_dangerous_command(&args.cmd) {
200            return Err(Error::ExecutionFailed(format!("dangerous command blocked: {}", reason)));
201        }
202        let mut cmd = Command::new("sh");
203        cmd.arg("-c").arg(&args.cmd);
204        if let Some(cwd) = &args.cwd {
205            let path_ctx = PathContext { require_exists: true, require_file: false, ..Default::default() };
206            let cwd_path = validate_path(cwd, &path_ctx).map_err(|e| Error::ExecutionFailed(format!("invalid cwd: {}", e)))?;
207            cmd.current_dir(cwd_path);
208        }
209        let mut child = cmd.process_group(0).stdout(std::process::Stdio::piped()).stderr(std::process::Stdio::piped()).stdin(if args.stdin.is_some() { std::process::Stdio::piped() } else { std::process::Stdio::null() }).spawn().map_err(|e| Error::ExecutionFailed(format!("failed to spawn: {}", e)))?;
210        let child_pid = child.id();
211        let pgid = child_pid;
212        if let Some(ref stdin_content) = args.stdin {
213            if stdin_content.len() > MAX_STDIN_BYTES { return Err(Error::ExecutionFailed("stdin too large".into())); }
214            if let Some(mut stdin_pipe) = child.stdin.take() { let _ = stdin_pipe.write_all(stdin_content.as_bytes()); }
215        }
216        let (exit_status, stdout, stderr, descendants) = wait_with_timeout(&mut child, pgid, timeout)?;
217        let mut spawned_pids = vec![child_pid]; spawned_pids.extend(descendants);
218        let stdout_str = String::from_utf8_lossy(&stdout).to_string();
219        let stderr_str = String::from_utf8_lossy(&stderr).to_string();
220        let success = exit_status.success();
221
222        Ok(Output {
223            success,
224            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 }),
225            message: if success { Some("completed".into()) } else { Some(format!("exit code {}", exit_status.code().unwrap_or(-1))) }
226        })
227    }
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*; use crate::capability::Capability; use std::time::Instant;
233    #[test] fn executes_uptime() { let r = ShellExec.execute(&serde_json::json!({"cmd": "uptime"}), &Context { dry_run: false, job_id: "test".into(), working_dir: std::env::temp_dir() }).unwrap(); assert!(r.success); }
234    #[test] fn pipes_work() { let r = ShellExec.execute(&serde_json::json!({"cmd": "echo hi | cat"}), &Context { dry_run: false, job_id: "test".into(), working_dir: std::env::temp_dir() }).unwrap(); assert!(r.success); assert!(r.data["stdout"].as_str().unwrap().contains("hi")); }
235    #[test] fn chaining_works() { let r = ShellExec.execute(&serde_json::json!({"cmd": "echo a && echo b"}), &Context { dry_run: false, job_id: "test".into(), working_dir: std::env::temp_dir() }).unwrap(); assert!(r.success); }
236    #[test] fn blocks_dangerous() { assert!(ShellExec.execute(&serde_json::json!({"cmd": "mkfs"}), &Context { dry_run: false, job_id: "test".into(), working_dir: std::env::temp_dir() }).is_err()); }
237    #[test] fn enforces_timeout() { let s = Instant::now(); assert!(ShellExec.execute(&serde_json::json!({"cmd": "sleep 5", "timeout_secs": 1}), &Context { dry_run: false, job_id: "test".into(), working_dir: std::env::temp_dir() }).is_err()); assert!(s.elapsed().as_secs() < 3); }
238}