runtimo-core 0.7.0

Agent-centric capability runtime with telemetry, process tracking, and crash recovery
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
//! ShellExec capability — execute shell commands with full telemetry and audit trail.
//!
//! All commands execute via `sh -c`, providing full shell functionality:
//! - Pipes: `ls | head -5`
//! - Redirects: `echo hello > /tmp/file.txt`
//! - Chaining: `echo first && echo second`
//!
//! # Guardrails (not security)
//!
//! **Threat model:** Agents making mistakes, not attackers.
//! The blocklist catches obvious agent hallucinations/bugs.
//!
//! **What's blocked:**
//! - Filesystem destruction: `rm -rf /`, `rm --recursive` on system dirs (`/home`, `/etc`, `/usr`, `/var`, `/lib`, `/opt`, `/bin`, `/sbin`)
//! - Shell expansion bypasses: `rm -rf ~` (tilde expansion)
//! - Filesystem creation: `mkfs.*`, `mkswap`
//! - Data destruction: `dd if=/dev/zero`
//! - System commands: `shutdown`, `reboot`, `poweroff`
//! - Disk operations: `fdisk`, `parted`
//! - Permission/ownership changes: `chown`, `chgrp`, `chmod 777 /`
//! - Mount operations: `mount`, `umount`
//! - Firewall manipulation: `iptables`, `nft`
//! - Outbound network tools: `curl`, `wget`, `nc`, `ncat`, `socat`, `ssh`, `scp`, `telnet`
//!   (gated behind `RUNTIMO_ENABLE_NETWORK=1` env var)
//!
//! **PATH sanitization:**
//! ShellExec sets `PATH=/usr/local/bin:/usr/bin:/bin` to limit
//! which executables the command can invoke. Custom binaries
//! in non-standard locations are not resolvable.
//!
//! **What protects you:**
//! - Dangerous command blocklist
//! - Network command gating (opt-in via `RUNTIMO_ENABLE_NETWORK`)
//! - PATH sanitization to known-safe directories
//! - Resource limits (timeout, process isolation)
//! - WAL audit trail (supports undo/recovery)
//!
//! # Features
//!
//! - Timeout enforcement (default 30s, configurable)
//! - Output capture (stdout/stderr, bounded to 10MB)
//! - PID tracking (child + grandchildren via /proc/{pid}/children)
//! - Process group isolation (kills all descendants on timeout)
//! - Telemetry before/after execution
//! - WAL logging for audit trail
//! - Stdin pipe support
//!
//! # Example
//!
//! ```rust,ignore
//! use runtimo_core::capabilities::ShellExec;
//! use runtimo_core::capability::{Capability, Context};
//! use serde_json::json;
//!
//! let result = ShellExec.execute(
//!     &json!({"cmd": "ls | head -5", "timeout_secs": 10}),
//!     &Context { dry_run: false, job_id: "test".into(), working_dir: std::env::temp_dir() }
//! ).unwrap();
//! ```

use crate::capability::{Capability, Context, Output};
use crate::validation::path::{validate_path, PathContext};
use crate::{Error, Result};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::fs;
use std::io::{Read, Write};
use std::os::unix::process::CommandExt;
use std::process::{Child, Command, ExitStatus};
use std::thread;
use std::time::{Duration, Instant};

type WaitResult = Result<(ExitStatus, Vec<u8>, Vec<u8>, Vec<u32>)>;

const DEFAULT_TIMEOUT_SECS: u64 = 30;
const MAX_OUTPUT_BYTES: usize = 10 * 1024 * 1024;
const MAX_STDIN_BYTES: usize = 1024 * 1024;

/// Input parameters for [`ShellExec::execute`].
///
/// Runs a shell command with an optional timeout and working directory.
/// Dangerous commands (rm -rf /, dd, fork bombs) are rejected before execution.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ShellExecArgs {
    /// Shell command to execute (e.g. `"ls -la"`, `"cargo build"`).
    #[serde(alias = "command")]
    pub cmd: String,
    /// Maximum seconds before the process is killed (default: 30).
    pub timeout_secs: Option<u64>,
    /// Working directory for the command (default: executor CWD).
    pub cwd: Option<String>,
    /// Data piped to the command's stdin.
    pub stdin: Option<String>,
}

/// Tests whether a command prefix (first whitespace-delimited token) matches
/// any entry in the given list. Avoids false-positives from substrings
/// (e.g. "ssh" in "ssh-agent" is fine when `ssh` is a prefix match but not
/// when it appears mid-word).
fn command_matches(cmd_lower: &str, names: &[&str]) -> bool {
    let first_token = cmd_lower.split_whitespace().next().unwrap_or("");
    // Also check for pipe/chaining context: `echo foo | curl ...` or `true && curl ...`
    for part in cmd_lower.split(['|', '&', ';']) {
        let t = part.trim();
        if names
            .iter()
            .any(|n| t == *n || t.starts_with(&format!("{} ", n)))
        {
            return true;
        }
    }
    names.contains(&first_token)
}

fn is_dangerous_command(cmd: &str) -> Option<&'static str> {
    let cmd_lower = cmd.to_lowercase();
    if cmd_lower.contains("mkfs") || cmd_lower.contains("mkswap") {
        return Some("filesystem creation commands are blocked");
    }
    if cmd_lower.contains("fdisk") || cmd_lower.contains("parted") {
        return Some("disk partitioning commands are blocked");
    }
    if cmd_lower.contains(" dd ") || cmd_lower.starts_with("dd ") || cmd_lower.contains(" dd") {
        return Some("dd (disk destroyer) is blocked");
    }
    if cmd_lower.contains("shutdown")
        || cmd_lower.contains("reboot")
        || cmd_lower.contains("poweroff")
    {
        return Some("system power commands are blocked");
    }
    // chown/chgrp — ownership changes
    if command_matches(&cmd_lower, &["chown", "chgrp"]) {
        return Some("ownership change commands are blocked");
    }
    // mount/unmount — filesystem mount operations
    if command_matches(&cmd_lower, &["mount", "umount"]) {
        return Some("mount/unmount commands are blocked");
    }
    // iptables/nft — firewall manipulation
    if command_matches(&cmd_lower, &["iptables", "nft"]) {
        return Some("firewall manipulation commands are blocked");
    }
    if cmd_lower.contains("rm")
        && (cmd_lower.contains("-rf")
            || cmd_lower.contains("-fr")
            || cmd_lower.contains("--recursive")
            || cmd_lower.contains(" -r ")
            || cmd_lower.contains(" -f "))
        && (cmd_lower.contains(" / ")
            || cmd_lower.contains("/*")
            || cmd_lower.contains("/dev")
            || cmd_lower.contains("/boot")
            || cmd_lower.contains("/home")
            || cmd_lower.contains("/etc")
            || cmd_lower.contains("/usr")
            || cmd_lower.contains("/var")
            || cmd_lower.contains("/lib")
            || cmd_lower.contains("/opt")
            || cmd_lower.contains("/bin")
            || cmd_lower.contains("/sbin"))
    {
        return Some("rm -rf / --recursive on system directories is blocked");
    }
    if cmd_lower.contains("rm")
        && (cmd_lower.contains("-rf")
            || cmd_lower.contains("-fr")
            || cmd_lower.contains("--recursive")
            || cmd_lower.contains(" -r ")
            || cmd_lower.contains(" -f "))
        && cmd_lower.contains('~')
    {
        return Some("rm with shell expansions is blocked — use explicit paths");
    }
    if cmd_lower.contains("chmod") && cmd_lower.contains("777") && cmd_lower.contains(" /") {
        return Some("chmod 777 / is blocked");
    }
    None
}

/// Tests whether a command invokes a network client.
///
/// Blocked tools: `curl`, `wget`, `nc`/`ncat`/`netcat`, `socat`,
/// `ssh`, `scp`, `telnet`.
///
/// These are only blocked when `RUNTIMO_ENABLE_NETWORK` is not set to `"1"`.
fn is_network_command(cmd: &str) -> bool {
    let cmd_lower = cmd.to_lowercase();
    command_matches(
        &cmd_lower,
        &[
            "curl", "wget", "nc", "ncat", "netcat", "socat", "ssh", "scp", "telnet",
        ],
    )
}

/// Checks whether outbound network commands are permitted.
///
/// Returns `true` when network tools are allowed (env var set to `"1"`).
fn network_enabled() -> bool {
    std::env::var("RUNTIMO_ENABLE_NETWORK").as_deref() == Ok("1")
}

#[allow(clippy::arithmetic_side_effects)] // -(pgid) negation is safe for valid PIDs
fn wait_with_timeout(child: &mut Child, pgid: u32, timeout_secs: u64) -> WaitResult {
    let start = Instant::now();
    let timeout = Duration::from_secs(timeout_secs);
    let child_pid = child.id();
    let stdout_thread = child.stdout.take().map(|stdout| {
        thread::spawn(move || {
            let mut data = Vec::new();
            let _ = stdout.take(MAX_OUTPUT_BYTES as u64).read_to_end(&mut data);
            data
        })
    });
    let stderr_thread = child.stderr.take().map(|stderr| {
        thread::spawn(move || {
            let mut data = Vec::new();
            let _ = stderr.take(MAX_OUTPUT_BYTES as u64).read_to_end(&mut data);
            data
        })
    });
    let mut last_descendants: Vec<u32>;
    loop {
        if start.elapsed() > timeout {
            // SAFETY: pgid is a valid process group ID from the spawned child; SIGKILL is well-defined;
            // pgid as pid_t may wrap on 32-bit but pgid is always within pid_t range
            #[allow(clippy::cast_possible_wrap)]
            unsafe {
                let _ = libc::kill(-(pgid as libc::pid_t), libc::SIGKILL);
            }
            let killed_descendants = get_all_descendants(child_pid);
            let _ = child.wait();
            let _ = stdout_thread.map(|h| h.join().unwrap_or_default());
            let _ = stderr_thread.map(|h| h.join().unwrap_or_default());
            return Err(Error::ExecutionFailed(format!(
                "command timed out after {}s (killed {} descendants)",
                timeout_secs,
                killed_descendants.len()
            )));
        }
        last_descendants = get_all_descendants(child_pid);
        match child.try_wait() {
            Ok(Some(status)) => {
                let stdout_data = stdout_thread
                    .map(|h| h.join().unwrap_or_default())
                    .unwrap_or_default();
                let stderr_data = stderr_thread
                    .map(|h| h.join().unwrap_or_default())
                    .unwrap_or_default();
                return Ok((status, stdout_data, stderr_data, last_descendants));
            }
            Ok(None) => std::thread::sleep(Duration::from_millis(50)),
            Err(e) => return Err(Error::ExecutionFailed(format!("error waiting: {}", e))),
        }
    }
}

fn get_direct_children(pid: u32) -> Vec<u32> {
    let children_path = format!("/proc/{}/children", pid);
    if let Ok(content) = fs::read_to_string(&children_path) {
        content
            .split_whitespace()
            .filter_map(|s| s.parse::<u32>().ok())
            .collect()
    } else {
        Vec::new()
    }
}

fn get_all_descendants(pid: u32) -> Vec<u32> {
    let mut descendants = Vec::new();
    let mut stack = vec![pid];
    let mut visited = std::collections::HashSet::new();
    while let Some(current) = stack.pop() {
        if visited.contains(&current) {
            continue;
        }
        visited.insert(current);
        let children = get_direct_children(current);
        if children.is_empty() {
            if let Ok(output) = std::process::Command::new("pgrep")
                .arg("-P")
                .arg(current.to_string())
                .output()
            {
                if output.status.success() {
                    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());
                    for child in pgrep_children {
                        if !visited.contains(&child) {
                            descendants.push(child);
                            stack.push(child);
                        }
                    }
                    continue;
                }
            }
        }
        for child in children {
            if !visited.contains(&child) {
                descendants.push(child);
                stack.push(child);
            }
        }
    }
    descendants
}

/// Capability that executes shell commands with safety guards.
///
/// Commands are run in the executor's process group with a configurable
/// timeout. A blocklist rejects destructive commands (e.g. `rm -rf /`,
/// `dd if=/dev/zero of=/dev/sda`). All executions are logged to the WAL.
#[allow(clippy::exhaustive_structs)]
pub struct ShellExec;

impl Capability for ShellExec {
    fn name(&self) -> &'static str {
        "ShellExec"
    }
    fn description(&self) -> &'static str {
        "exec cmd via sh -c, timeout, audit. Dangerous cmds: mkfs,fdisk,dd,shutdown,rm -rf / blocked."
    }
    fn schema(&self) -> Value {
        serde_json::json!({
            "type": "object",
            "properties": {
                "cmd": { "type": "string", "description": "Command to execute via sh -c" },
                "timeout_secs": { "type": "integer", "minimum": 1, "maximum": 300 },
                "cwd": { "type": "string" },
                "stdin": { "type": "string" }
            },
            "required": ["cmd"]
        })
    }
    fn validate(&self, args: &Value) -> Result<()> {
        let args: ShellExecArgs = serde_json::from_value(args.clone())
            .map_err(|e| Error::SchemaValidationFailed(e.to_string()))?;
        if args.cmd.is_empty() {
            return Err(Error::SchemaValidationFailed("cmd is empty".into()));
        }
        Ok(())
    }
    fn execute(&self, args: &Value, ctx: &Context) -> Result<Output> {
        if ctx.dry_run {
            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()),
            });
        }
        let args: ShellExecArgs = serde_json::from_value(args.clone())
            .map_err(|e| Error::ExecutionFailed(e.to_string()))?;
        let timeout = args.timeout_secs.unwrap_or(DEFAULT_TIMEOUT_SECS);
        if let Some(reason) = is_dangerous_command(&args.cmd) {
            return Err(Error::ExecutionFailed(format!(
                "dangerous command blocked: {}",
                reason
            )));
        }
        if !network_enabled() && is_network_command(&args.cmd) {
            return Err(Error::ExecutionFailed(
                "network commands blocked — set RUNTIMO_ENABLE_NETWORK=1 to enable".into(),
            ));
        }
        let mut cmd = Command::new("sh");
        // PATH sanitization: limit executable resolution to trusted system dirs.
        // This is defense-in-depth — the blocklist catches known-dangerous
        // commands, but this prevents invocation of custom binaries in
        // non-standard locations.
        cmd.env("PATH", "/usr/local/bin:/usr/bin:/bin");
        cmd.arg("-c").arg(&args.cmd);
        if let Some(cwd) = &args.cwd {
            let path_ctx = PathContext {
                require_exists: true,
                require_file: false,
                ..Default::default()
            };
            let cwd_path = validate_path(cwd, &path_ctx)
                .map_err(|e| Error::ExecutionFailed(format!("invalid cwd: {}", e)))?;
            cmd.current_dir(cwd_path);
        }
        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)))?;
        let child_pid = child.id();
        let pgid = child_pid;
        if let Some(ref stdin_content) = args.stdin {
            if stdin_content.len() > MAX_STDIN_BYTES {
                return Err(Error::ExecutionFailed("stdin too large".into()));
            }
            if let Some(mut stdin_pipe) = child.stdin.take() {
                let _ = stdin_pipe.write_all(stdin_content.as_bytes());
            }
        }
        let (exit_status, stdout, stderr, descendants) =
            wait_with_timeout(&mut child, pgid, timeout)?;
        let mut spawned_pids = vec![child_pid];
        spawned_pids.extend(descendants);
        let stdout_str = String::from_utf8_lossy(&stdout).to_string();
        let stderr_str = String::from_utf8_lossy(&stderr).to_string();
        let success = exit_status.success();

        Ok(Output {
            success,
            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 }),
            message: if success {
                Some("completed".into())
            } else {
                Some(format!("exit code {}", exit_status.code().unwrap_or(-1)))
            },
        })
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::capability::Capability;
    use std::time::Instant;
    #[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);
    }
    #[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"));
    }
    #[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);
    }
    #[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());
    }
    #[test]
    fn blocks_recursive_flag() {
        // rm --recursive (long form) should be caught like -rf
        assert!(ShellExec
            .execute(
                &serde_json::json!({"cmd": "rm --recursive /home"}),
                &Context {
                    dry_run: false,
                    job_id: "test".into(),
                    working_dir: std::env::temp_dir()
                }
            )
            .is_err());
    }
    #[test]
    fn blocks_ownership_commands() {
        for cmd in &["chown root /tmp/x", "chgrp staff /tmp/x"] {
            assert!(
                ShellExec
                    .execute(
                        &serde_json::json!({"cmd": cmd}),
                        &Context {
                            dry_run: false,
                            job_id: "test".into(),
                            working_dir: std::env::temp_dir()
                        }
                    )
                    .is_err(),
                "should block: {}",
                cmd
            );
        }
    }
    #[test]
    fn blocks_mount_commands() {
        for cmd in &["mount /dev/sda1 /mnt", "umount /mnt"] {
            assert!(
                ShellExec
                    .execute(
                        &serde_json::json!({"cmd": cmd}),
                        &Context {
                            dry_run: false,
                            job_id: "test".into(),
                            working_dir: std::env::temp_dir()
                        }
                    )
                    .is_err(),
                "should block: {}",
                cmd
            );
        }
    }
    #[test]
    fn blocks_firewall_commands() {
        for cmd in &["iptables -L", "nft list ruleset"] {
            assert!(
                ShellExec
                    .execute(
                        &serde_json::json!({"cmd": cmd}),
                        &Context {
                            dry_run: false,
                            job_id: "test".into(),
                            working_dir: std::env::temp_dir()
                        }
                    )
                    .is_err(),
                "should block: {}",
                cmd
            );
        }
    }
    #[test]
    fn blocks_network_commands_by_default() {
        // Ensure RUNTIMO_ENABLE_NETWORK is not set
        std::env::remove_var("RUNTIMO_ENABLE_NETWORK");
        for cmd in &[
            "curl http://example.com",
            "wget http://example.com",
            "nc example.com 80",
        ] {
            assert!(
                ShellExec
                    .execute(
                        &serde_json::json!({"cmd": cmd}),
                        &Context {
                            dry_run: false,
                            job_id: "test".into(),
                            working_dir: std::env::temp_dir()
                        }
                    )
                    .is_err(),
                "should block network cmd: {}",
                cmd
            );
        }
    }
    #[test]
    fn allows_network_commands_when_enabled() {
        std::env::set_var("RUNTIMO_ENABLE_NETWORK", "1");
        // curl --version should work (non-destructive)
        let r = ShellExec.execute(
            &serde_json::json!({"cmd": "curl --version"}),
            &Context {
                dry_run: false,
                job_id: "test".into(),
                working_dir: std::env::temp_dir(),
            },
        );
        std::env::remove_var("RUNTIMO_ENABLE_NETWORK");
        // May fail if curl not installed, but should NOT fail with "network commands blocked"
        match r {
            Ok(o) => assert!(o.success, "curl --version should succeed when enabled"),
            Err(e) => {
                let msg = e.to_string();
                assert!(
                    !msg.contains("network commands blocked"),
                    "should NOT block network when RUNTIMO_ENABLE_NETWORK=1, got: {}",
                    msg
                );
            }
        }
    }
    #[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);
    }
}