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 /`, `rm --recursive` on system dirs (`/home`, `/etc`, `/usr`, `/var`, `/lib`, `/opt`, `/bin`, `/sbin`)
15//! - Shell expansion bypasses: `rm -rf ~` (tilde expansion)
16//! - Filesystem creation: `mkfs.*`, `mkswap`
17//! - Data destruction: `dd if=/dev/zero`
18//! - System commands: `shutdown`, `reboot`, `poweroff`
19//! - Disk operations: `fdisk`, `parted`
20//! - Permission/ownership changes: `chown`, `chgrp`, `chmod 777 /`
21//! - Mount operations: `mount`, `umount`
22//! - Firewall manipulation: `iptables`, `nft`
23//! - Outbound network tools: `curl`, `wget`, `nc`, `ncat`, `socat`, `ssh`, `scp`, `telnet`
24//!   (gated behind `RUNTIMO_ENABLE_NETWORK=1` env var)
25//!
26//! **PATH sanitization:**
27//! ShellExec sets `PATH=/usr/local/bin:/usr/bin:/bin` to limit
28//! which executables the command can invoke. Custom binaries
29//! in non-standard locations are not resolvable.
30//!
31//! **What protects you:**
32//! - Dangerous command blocklist
33//! - Network command gating (opt-in via `RUNTIMO_ENABLE_NETWORK`)
34//! - PATH sanitization to known-safe directories
35//! - Resource limits (timeout, process isolation)
36//! - WAL audit trail (supports undo/recovery)
37//!
38//! # Features
39//!
40//! - Timeout enforcement (default 30s, configurable)
41//! - Output capture (stdout/stderr, bounded to 10MB)
42//! - PID tracking (child + grandchildren via /proc/{pid}/children)
43//! - Process group isolation (kills all descendants on timeout)
44//! - Telemetry before/after execution
45//! - WAL logging for audit trail
46//! - Stdin pipe support
47//!
48//! # Example
49//!
50//! ```rust,ignore
51//! use runtimo_core::capabilities::ShellExec;
52//! use runtimo_core::capability::{Capability, Context};
53//! use serde_json::json;
54//!
55//! let result = ShellExec.execute(
56//!     &json!({"cmd": "ls | head -5", "timeout_secs": 10}),
57//!     &Context { dry_run: false, job_id: "test".into(), working_dir: std::env::temp_dir() }
58//! ).unwrap();
59//! ```
60
61use 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/// Input parameters for [`ShellExec::execute`].
80///
81/// Runs a shell command with an optional timeout and working directory.
82/// Dangerous commands (rm -rf /, dd, fork bombs) are rejected before execution.
83#[derive(Debug, Clone, Serialize, Deserialize)]
84pub struct ShellExecArgs {
85    /// Shell command to execute (e.g. `"ls -la"`, `"cargo build"`).
86    #[serde(alias = "command")]
87    pub cmd: String,
88    /// Maximum seconds before the process is killed (default: 30).
89    pub timeout_secs: Option<u64>,
90    /// Working directory for the command (default: executor CWD).
91    pub cwd: Option<String>,
92    /// Data piped to the command's stdin.
93    pub stdin: Option<String>,
94}
95
96/// Tests whether a command prefix (first whitespace-delimited token) matches
97/// any entry in the given list. Avoids false-positives from substrings
98/// (e.g. "ssh" in "ssh-agent" is fine when `ssh` is a prefix match but not
99/// when it appears mid-word).
100fn command_matches(cmd_lower: &str, names: &[&str]) -> bool {
101    let first_token = cmd_lower.split_whitespace().next().unwrap_or("");
102    // Also check for pipe/chaining context: `echo foo | curl ...` or `true && curl ...`
103    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    // chown/chgrp — ownership changes
133    if command_matches(&cmd_lower, &["chown", "chgrp"]) {
134        return Some("ownership change commands are blocked");
135    }
136    // mount/unmount — filesystem mount operations
137    if command_matches(&cmd_lower, &["mount", "umount"]) {
138        return Some("mount/unmount commands are blocked");
139    }
140    // iptables/nft — firewall manipulation
141    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
181/// Tests whether a command invokes a network client.
182///
183/// Blocked tools: `curl`, `wget`, `nc`/`ncat`/`netcat`, `socat`,
184/// `ssh`, `scp`, `telnet`.
185///
186/// These are only blocked when `RUNTIMO_ENABLE_NETWORK` is not set to `"1"`.
187fn 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
197/// Checks whether outbound network commands are permitted.
198///
199/// Returns `true` when network tools are allowed (env var set to `"1"`).
200fn network_enabled() -> bool {
201    std::env::var("RUNTIMO_ENABLE_NETWORK").as_deref() == Ok("1")
202}
203
204#[allow(clippy::arithmetic_side_effects)] // -(pgid) negation is safe for valid PIDs
205fn 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            // SAFETY: pgid is a valid process group ID from the spawned child; SIGKILL is well-defined;
227            // pgid as pid_t may wrap on 32-bit but pgid is always within pid_t range
228            #[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(&current) {
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/// Capability that executes shell commands with safety guards.
313///
314/// Commands are run in the executor's process group with a configurable
315/// timeout. A blocklist rejects destructive commands (e.g. `rm -rf /`,
316/// `dd if=/dev/zero of=/dev/sda`). All executions are logged to the WAL.
317#[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        // PATH sanitization: limit executable resolution to trusted system dirs.
371        // This is defense-in-depth — the blocklist catches known-dangerous
372        // commands, but this prevents invocation of custom binaries in
373        // non-standard locations.
374        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        // rm --recursive (long form) should be caught like -rf
491        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        // Ensure RUNTIMO_ENABLE_NETWORK is not set
562        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        // curl --version should work (non-destructive)
588        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        // May fail if curl not installed, but should NOT fail with "network commands blocked"
598        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}