runtimo_core/capabilities/
shell_exec.rs1use crate::capability::{Capability, Context, Output};
50use crate::validation::path::{validate_path, PathContext};
51use crate::{Error, Result};
52use serde::{Deserialize, Serialize};
53use serde_json::Value;
54use std::fs;
55use std::io::{Read, Write};
56use std::os::unix::process::CommandExt;
57use std::process::{Child, Command, ExitStatus};
58use std::thread;
59use std::time::{Duration, Instant};
60
61type WaitResult = Result<(ExitStatus, Vec<u8>, Vec<u8>, Vec<u32>)>;
62
63const DEFAULT_TIMEOUT_SECS: u64 = 30;
64const MAX_OUTPUT_BYTES: usize = 10 * 1024 * 1024;
65const MAX_STDIN_BYTES: usize = 1024 * 1024;
66
67#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct ShellExecArgs {
69 #[serde(alias = "command")]
70 pub cmd: String,
71 pub timeout_secs: Option<u64>,
72 pub cwd: Option<String>,
73 pub stdin: Option<String>,
74}
75
76fn is_dangerous_command(cmd: &str) -> Option<&'static str> {
77 let cmd_lower = cmd.to_lowercase();
78 if cmd_lower.contains("mkfs") || cmd_lower.contains("mkswap") {
79 return Some("filesystem creation commands are blocked");
80 }
81 if cmd_lower.contains("fdisk") || cmd_lower.contains("parted") {
82 return Some("disk partitioning commands are blocked");
83 }
84 if cmd_lower.contains(" dd ") || cmd_lower.starts_with("dd ") || cmd_lower.contains(" dd") {
85 return Some("dd (disk destroyer) is blocked");
86 }
87 if cmd_lower.contains("shutdown")
88 || cmd_lower.contains("reboot")
89 || cmd_lower.contains("poweroff")
90 {
91 return Some("system power commands are blocked");
92 }
93 if cmd_lower.contains("rm")
94 && (cmd_lower.contains("-rf")
95 || cmd_lower.contains("-fr")
96 || cmd_lower.contains(" -r ")
97 || cmd_lower.contains(" -f "))
98 && (cmd_lower.contains(" / ")
99 || cmd_lower.contains("/*")
100 || cmd_lower.contains("/dev")
101 || cmd_lower.contains("/boot")
102 || cmd_lower.contains("/home")
103 || cmd_lower.contains("/etc")
104 || cmd_lower.contains("/usr")
105 || cmd_lower.contains("/var")
106 || cmd_lower.contains("/lib")
107 || cmd_lower.contains("/opt")
108 || cmd_lower.contains("/bin")
109 || cmd_lower.contains("/sbin"))
110 {
111 return Some("rm -rf on system directories is blocked");
112 }
113 if cmd_lower.contains("rm")
114 && (cmd_lower.contains("-rf")
115 || cmd_lower.contains("-fr")
116 || cmd_lower.contains(" -r ")
117 || cmd_lower.contains(" -f "))
118 && cmd_lower.contains('~')
119 {
120 return Some("rm -rf with shell expansions is blocked — use explicit paths");
121 }
122 if cmd_lower.contains("chmod") && cmd_lower.contains("777") && cmd_lower.contains(" /") {
123 return Some("chmod 777 / is blocked");
124 }
125 None
126}
127
128#[allow(clippy::arithmetic_side_effects)] fn wait_with_timeout(child: &mut Child, pgid: u32, timeout_secs: u64) -> WaitResult {
130 let start = Instant::now();
131 let timeout = Duration::from_secs(timeout_secs);
132 let child_pid = child.id();
133 let stdout_thread = child.stdout.take().map(|stdout| {
134 thread::spawn(move || {
135 let mut data = Vec::new();
136 let _ = stdout.take(MAX_OUTPUT_BYTES as u64).read_to_end(&mut data);
137 data
138 })
139 });
140 let stderr_thread = child.stderr.take().map(|stderr| {
141 thread::spawn(move || {
142 let mut data = Vec::new();
143 let _ = stderr.take(MAX_OUTPUT_BYTES as u64).read_to_end(&mut data);
144 data
145 })
146 });
147 let mut last_descendants: Vec<u32>;
148 loop {
149 if start.elapsed() > timeout {
150 #[allow(clippy::cast_possible_wrap)]
153 unsafe {
154 let _ = libc::kill(-(pgid as libc::pid_t), libc::SIGKILL);
155 }
156 let killed_descendants = get_all_descendants(child_pid);
157 let _ = child.wait();
158 let _ = stdout_thread.map(|h| h.join().unwrap_or_default());
159 let _ = stderr_thread.map(|h| h.join().unwrap_or_default());
160 return Err(Error::ExecutionFailed(format!(
161 "command timed out after {}s (killed {} descendants)",
162 timeout_secs,
163 killed_descendants.len()
164 )));
165 }
166 last_descendants = get_all_descendants(child_pid);
167 match child.try_wait() {
168 Ok(Some(status)) => {
169 let stdout_data = stdout_thread
170 .map(|h| h.join().unwrap_or_default())
171 .unwrap_or_default();
172 let stderr_data = stderr_thread
173 .map(|h| h.join().unwrap_or_default())
174 .unwrap_or_default();
175 return Ok((status, stdout_data, stderr_data, last_descendants));
176 }
177 Ok(None) => std::thread::sleep(Duration::from_millis(50)),
178 Err(e) => return Err(Error::ExecutionFailed(format!("error waiting: {}", e))),
179 }
180 }
181}
182
183fn get_direct_children(pid: u32) -> Vec<u32> {
184 let children_path = format!("/proc/{}/children", pid);
185 if let Ok(content) = fs::read_to_string(&children_path) {
186 content
187 .split_whitespace()
188 .filter_map(|s| s.parse::<u32>().ok())
189 .collect()
190 } else {
191 Vec::new()
192 }
193}
194
195fn get_all_descendants(pid: u32) -> Vec<u32> {
196 let mut descendants = Vec::new();
197 let mut stack = vec![pid];
198 let mut visited = std::collections::HashSet::new();
199 while let Some(current) = stack.pop() {
200 if visited.contains(¤t) {
201 continue;
202 }
203 visited.insert(current);
204 let children = get_direct_children(current);
205 if children.is_empty() {
206 if let Ok(output) = std::process::Command::new("pgrep")
207 .arg("-P")
208 .arg(current.to_string())
209 .output()
210 {
211 if output.status.success() {
212 let pgrep_lines = String::from_utf8_lossy(&output.stdout).to_string();
213 let pgrep_children = pgrep_lines
214 .lines()
215 .filter_map(|s| s.trim().parse::<u32>().ok());
216 for child in pgrep_children {
217 if !visited.contains(&child) {
218 descendants.push(child);
219 stack.push(child);
220 }
221 }
222 continue;
223 }
224 }
225 }
226 for child in children {
227 if !visited.contains(&child) {
228 descendants.push(child);
229 stack.push(child);
230 }
231 }
232 }
233 descendants
234}
235
236#[allow(clippy::exhaustive_structs)]
237pub struct ShellExec;
238
239impl Capability for ShellExec {
240 fn name(&self) -> &'static str {
241 "ShellExec"
242 }
243 fn description(&self) -> &'static str {
244 "exec cmd via sh -c, timeout, audit. Dangerous cmds: mkfs,fdisk,dd,shutdown,rm -rf / blocked."
245 }
246 fn schema(&self) -> Value {
247 serde_json::json!({
248 "type": "object",
249 "properties": {
250 "cmd": { "type": "string", "description": "Command to execute via sh -c" },
251 "timeout_secs": { "type": "integer", "minimum": 1, "maximum": 300 },
252 "cwd": { "type": "string" },
253 "stdin": { "type": "string" }
254 },
255 "required": ["cmd"]
256 })
257 }
258 fn validate(&self, args: &Value) -> Result<()> {
259 let args: ShellExecArgs = serde_json::from_value(args.clone())
260 .map_err(|e| Error::SchemaValidationFailed(e.to_string()))?;
261 if args.cmd.is_empty() {
262 return Err(Error::SchemaValidationFailed("cmd is empty".into()));
263 }
264 Ok(())
265 }
266 fn execute(&self, args: &Value, ctx: &Context) -> Result<Output> {
267 if ctx.dry_run {
268 return Ok(Output {
269 success: true,
270 data: serde_json::json!({ "cmd": args.get("cmd").and_then(|v| v.as_str()).unwrap_or(""), "dry_run": true }),
271 message: Some("DRY RUN".into()),
272 });
273 }
274 let args: ShellExecArgs = serde_json::from_value(args.clone())
275 .map_err(|e| Error::ExecutionFailed(e.to_string()))?;
276 let timeout = args.timeout_secs.unwrap_or(DEFAULT_TIMEOUT_SECS);
277 if let Some(reason) = is_dangerous_command(&args.cmd) {
278 return Err(Error::ExecutionFailed(format!(
279 "dangerous command blocked: {}",
280 reason
281 )));
282 }
283 let mut cmd = Command::new("sh");
284 cmd.arg("-c").arg(&args.cmd);
285 if let Some(cwd) = &args.cwd {
286 let path_ctx = PathContext {
287 require_exists: true,
288 require_file: false,
289 ..Default::default()
290 };
291 let cwd_path = validate_path(cwd, &path_ctx)
292 .map_err(|e| Error::ExecutionFailed(format!("invalid cwd: {}", e)))?;
293 cmd.current_dir(cwd_path);
294 }
295 let mut child = cmd
296 .process_group(0)
297 .stdout(std::process::Stdio::piped())
298 .stderr(std::process::Stdio::piped())
299 .stdin(if args.stdin.is_some() {
300 std::process::Stdio::piped()
301 } else {
302 std::process::Stdio::null()
303 })
304 .spawn()
305 .map_err(|e| Error::ExecutionFailed(format!("failed to spawn: {}", e)))?;
306 let child_pid = child.id();
307 let pgid = child_pid;
308 if let Some(ref stdin_content) = args.stdin {
309 if stdin_content.len() > MAX_STDIN_BYTES {
310 return Err(Error::ExecutionFailed("stdin too large".into()));
311 }
312 if let Some(mut stdin_pipe) = child.stdin.take() {
313 let _ = stdin_pipe.write_all(stdin_content.as_bytes());
314 }
315 }
316 let (exit_status, stdout, stderr, descendants) =
317 wait_with_timeout(&mut child, pgid, timeout)?;
318 let mut spawned_pids = vec![child_pid];
319 spawned_pids.extend(descendants);
320 let stdout_str = String::from_utf8_lossy(&stdout).to_string();
321 let stderr_str = String::from_utf8_lossy(&stderr).to_string();
322 let success = exit_status.success();
323
324 Ok(Output {
325 success,
326 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 }),
327 message: if success {
328 Some("completed".into())
329 } else {
330 Some(format!("exit code {}", exit_status.code().unwrap_or(-1)))
331 },
332 })
333 }
334}
335
336#[cfg(test)]
337mod tests {
338 use super::*;
339 use crate::capability::Capability;
340 use std::time::Instant;
341 #[test]
342 fn executes_uptime() {
343 let r = ShellExec
344 .execute(
345 &serde_json::json!({"cmd": "uptime"}),
346 &Context {
347 dry_run: false,
348 job_id: "test".into(),
349 working_dir: std::env::temp_dir(),
350 },
351 )
352 .unwrap();
353 assert!(r.success);
354 }
355 #[test]
356 fn pipes_work() {
357 let r = ShellExec
358 .execute(
359 &serde_json::json!({"cmd": "echo hi | cat"}),
360 &Context {
361 dry_run: false,
362 job_id: "test".into(),
363 working_dir: std::env::temp_dir(),
364 },
365 )
366 .unwrap();
367 assert!(r.success);
368 assert!(r.data["stdout"].as_str().unwrap().contains("hi"));
369 }
370 #[test]
371 fn chaining_works() {
372 let r = ShellExec
373 .execute(
374 &serde_json::json!({"cmd": "echo a && echo b"}),
375 &Context {
376 dry_run: false,
377 job_id: "test".into(),
378 working_dir: std::env::temp_dir(),
379 },
380 )
381 .unwrap();
382 assert!(r.success);
383 }
384 #[test]
385 fn blocks_dangerous() {
386 assert!(ShellExec
387 .execute(
388 &serde_json::json!({"cmd": "mkfs"}),
389 &Context {
390 dry_run: false,
391 job_id: "test".into(),
392 working_dir: std::env::temp_dir()
393 }
394 )
395 .is_err());
396 }
397 #[test]
398 fn enforces_timeout() {
399 let s = Instant::now();
400 assert!(ShellExec
401 .execute(
402 &serde_json::json!({"cmd": "sleep 5", "timeout_secs": 1}),
403 &Context {
404 dry_run: false,
405 job_id: "test".into(),
406 working_dir: std::env::temp_dir()
407 }
408 )
409 .is_err());
410 assert!(s.elapsed().as_secs() < 3);
411 }
412}