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` (all forms), `rm -rf /`, `rm --recursive`, `rm --no-preserve-root`
15//! - Secure deletion: `shred` (use FileWrite/Undo instead)
16//! - Fork bombs: `:(){ :|:& };:` and variants (self-referencing function definitions)
17//! - Shell expansion bypasses: `rm -rf ~` (tilde expansion)
18//! - Filesystem creation: `mkfs.*`, `mkswap`
19//! - Data destruction: `dd if=/dev/zero`
20//! - System commands: `shutdown`, `reboot`, `poweroff`
21//! - Disk operations: `fdisk`, `parted`
22//! - Permission/ownership changes: `chown`, `chgrp`, `chmod`
23//! - Mount operations: `mount`, `umount`
24//! - Firewall manipulation: `iptables`, `nft`
25//! - Process termination: `kill`, `killall`, `pkill` (use Kill capability instead)
26//! - Interpreters: `python`, `perl`, `ruby`, `node`, `lua`, etc. (opt-in via `RUNTIMO_ENABLE_INTERPRETERS`)
27//! - Outbound network tools: `curl`, `wget`, `nc`, `ncat`, `socat`, `ssh`, `scp`, `telnet`
28//!   (gated behind `RUNTIMO_ENABLE_NETWORK=1` env var)
29//!
30//! **PATH sanitization:**
31//! ShellExec sets `PATH=/usr/local/bin:/usr/bin:/bin` to limit
32//! which executables the command can invoke. Custom binaries
33//! in non-standard locations are not resolvable.
34//!
35//! **What protects you:**
36//! - Dangerous command blocklist
37//! - Network command gating (opt-in via `RUNTIMO_ENABLE_NETWORK`)
38//! - PATH sanitization to known-safe directories
39//! - Resource limits (timeout, process isolation)
40//! - WAL audit trail (supports undo/recovery)
41//!
42//! # Features
43//!
44//! - Timeout enforcement (default 30s, configurable)
45//! - Output capture (stdout/stderr, bounded to 10MB)
46//! - PID tracking (child PID only; spawned_pids removed from output)
47//! - Process group isolation (kills all descendants on timeout)
48//! - Telemetry before/after execution
49//! - WAL logging for audit trail
50//! - Stdin pipe support
51//!
52//! # Example
53//!
54//! ```rust,ignore
55//! use runtimo_core::capabilities::ShellExec;
56//! use runtimo_core::capability::{Capability, Context};
57//! use serde_json::json;
58//!
59//! let result = ShellExec.execute(
60//!     &json!({"cmd": "ls | head -5", "timeout_secs": 10}),
61//!     &Context { dry_run: false, job_id: "test".into(), working_dir: std::env::temp_dir() }
62//! ).unwrap();
63//! ```
64
65use crate::capability::{CapabilityError, Context, Output, TypedCapability};
66use crate::config::RuntimoConfig;
67use crate::validation::path::{validate_path, PathContext};
68use crate::{Error, Result};
69use serde::{Deserialize, Serialize};
70use serde_json::Value;
71use std::fs;
72use std::io::{Read, Write};
73use std::os::unix::process::CommandExt;
74use std::process::{Child, Command, ExitStatus};
75use std::thread;
76use std::time::{Duration, Instant};
77
78type WaitResult = Result<(ExitStatus, Vec<u8>, Vec<u8>, Vec<u32>)>;
79
80const DEFAULT_TIMEOUT_SECS: u64 = 30;
81const MAX_OUTPUT_BYTES: usize = 10 * 1024 * 1024;
82const MAX_STDIN_BYTES: usize = 1024 * 1024;
83
84/// Sensitive environment variable prefixes to strip before shell execution.
85///
86/// `_KEY`, `_TOKEN`, `_SECRET`, and `_PASSWORD` are checked as suffixes
87/// on the uppercased key name to catch `API_KEY`, `GITHUB_TOKEN`, etc.
88/// The prefix-based and suffix-based lists cover the most common patterns.
89/// GAP-07: This is prefix/suffix-based, not regex. Pattern variants
90/// like `MYPRIVATEKEY` (no underscore) are not caught.
91const SENSITIVE_ENV_PREFIXES: &[&str] = &[
92    "RUSTIMO_",
93    "AWS_",
94    "GITHUB_",
95    "GITLAB_",
96    "SSH_",
97    "GPG_",
98    "DOCKER_",
99    "VAULT_",
100    "NOMAD_",
101    "CONSUL_",
102    "HEROKU_",
103    "AZURE_",
104    "GCLOUD_",
105    "GOOGLE_CLOUD",
106    "GOOGLE_APPLICATION",
107    "SENTRY_DSN",
108    "DATADOG_",
109    "NEW_RELIC_",
110    "STRIPE_",
111    "TWILIO_",
112    "SENDGRID_",
113    "MAILGUN_",
114    "LDAP_",
115    "KRB5_",
116    "CUDA_", // CUDA_VISIBLE_DEVICES is non-secret but kept for defense-in-depth
117    // GAP-15: Dynamic linker attack surface — these control shared library
118    // loading and can inject arbitrary code into any spawned process.
119    "LD_",
120    "DYLD_",
121];
122
123const SENSITIVE_ENV_SUFFIXES: &[&str] = &[
124    "_KEY",
125    "_TOKEN",
126    "_SECRET",
127    "_PASSWORD",
128    "_SECRETS",
129    "_CREDENTIAL",
130    "_CREDENTIALS",
131    "_CERT",
132    "_CERTIFICATE",
133    "_PRIVATE_KEY",
134    "_ACCESS_KEY",
135    "_SECRET_KEY",
136    "_SIGNING_KEY",
137    "_ENCRYPTION_KEY",
138    "_DECRYPTION_KEY",
139    "_API_KEY",
140    "_AUTH_TOKEN",
141    "_DSN",
142    "_URL",
143];
144
145/// Safe environment variables to preserve during shell execution.
146const SAFE_ENV_VARS: &[&str] = &[
147    "HOME",
148    "USER",
149    "LOGNAME",
150    "PATH",
151    "TERM",
152    "LANG",
153    "LC_ALL",
154    "LC_CTYPE",
155    "TZ",
156    "PWD",
157    "OLDPWD",
158    "SHELL",
159    "EDITOR",
160    "VISUAL",
161    "DISPLAY",
162    "XAUTHORITY",
163    "WAYLAND_DISPLAY",
164    "DBUS_SESSION_BUS_ADDRESS",
165    "XDG_RUNTIME_DIR",
166    "XDG_SESSION_TYPE",
167    "XDG_CURRENT_DESKTOP",
168    "XDG_CONFIG_HOME",
169    "XDG_DATA_HOME",
170    "XDG_CACHE_HOME",
171    "COLORTERM",
172    "NO_COLOR",
173    "CLICOLOR",
174    "HOSTNAME",
175    "HOST",
176    "MACHTYPE",
177    "OSTYPE",
178    "SHLVL",
179    "LINENO",
180    "PPID",
181    "EUID",
182    "UID",
183    // Runtimo's own env vars are allowed for opt-in features
184    "RUNTIMO_ENABLE_NETWORK",
185    "RUNTIMO_ENABLE_INTERPRETERS",
186    // GAP-07: Known non-secret vars that could accidentally match suffix patterns.
187    // These are real environment variables used by tools/frameworks that happen
188    // to end in `_KEY` or `_URL` but do NOT contain secrets.
189    "FOREIGN_KEY",
190    "PRIMARY_KEY",
191    "PUBLIC_KEY",
192    "BROWSER_URL",
193    "BASE_URL",
194];
195
196/// Input parameters for [`ShellExec::execute`].
197///
198/// Runs a shell command with an optional timeout and working directory.
199/// Dangerous commands (rm -rf /, dd, fork bombs) are rejected before execution.
200#[derive(Debug, Clone, Serialize, Deserialize)]
201#[allow(clippy::exhaustive_structs)] // args struct — fields are the contract
202pub struct ShellExecArgs {
203    /// Shell command to execute (e.g. `"ls -la"`, `"cargo build"`).
204    #[serde(alias = "command")]
205    pub cmd: String,
206    /// Maximum seconds before the process is killed (default: 30).
207    pub timeout_secs: Option<u64>,
208    /// Working directory for the command (default: executor CWD).
209    pub cwd: Option<String>,
210    /// Data piped to the command's stdin.
211    pub stdin: Option<String>,
212}
213
214/// Tests whether a command prefix (first whitespace-delimited token) matches
215/// any entry in the given list. Avoids false-positives from substrings
216/// (e.g. "ssh" in "ssh-agent" is fine when `ssh` is a prefix match but not
217/// when it appears mid-word).
218fn command_matches(cmd_lower: &str, names: &[&str]) -> bool {
219    let first_token = cmd_lower.split_whitespace().next().unwrap_or("");
220    // Also check for pipe/chaining context: `echo foo | curl ...` or `true && curl ...`
221    for part in cmd_lower.split(['|', '&', ';']) {
222        let t = part.trim();
223        if names
224            .iter()
225            .any(|n| t == *n || t.starts_with(&format!("{} ", n)))
226        {
227            return true;
228        }
229    }
230    names.contains(&first_token)
231}
232
233/// Checks whether a command is inherently dangerous and must be blocked.
234///
235/// This is the primary blocklist gate. It checks both the original command
236/// and a detokenized version (to catch shell-quoting bypasses like `r"m"`).
237///
238/// # Categories blocked
239///
240/// - **Env dumpers:** `env`, `printenv`, `set`, `export`, `declare`, etc.
241/// - **Fork bombs:** `:(){ :|:& };:` and self-referencing function definitions
242/// - **Heredocs/herestrings:** `<<`, `<<<` (multi-line content bypasses single-line checks)
243/// - **Process substitution:** `<(cmd)`, `>(cmd)`
244/// - **Command substitution:** `$(cmd)`, `` `cmd` ``
245/// - **rm:** All forms (`rm /tmp/file`, `rm -rf /`, `r"m" /etc/passwd`)
246/// - **Filesystem:** `mkfs`, `mkswap`, `fdisk`, `parted`
247/// - **Data destruction:** `dd`, `shred`
248/// - **System:** `shutdown`, `reboot`, `poweroff`
249/// - **Ownership/permissions:** `chown`, `chgrp`, `chmod`
250/// - **Mount:** `mount`, `umount`
251/// - **Firewall:** `iptables`, `nft`
252/// - **Process termination:** `kill`, `killall`, `pkill`
253#[must_use]
254pub fn is_dangerous_command(cmd: &str) -> Option<&'static str> {
255    let cmd_lower = cmd.to_lowercase();
256    // Also check the detokenized version to catch shell-quoting bypasses
257    // (F-013: `r"m" -rf /` is detokenized to `rm -rf /` for substring matching)
258    let detok_lower = detokenize_command(&cmd_lower);
259
260    // ── F-015: Block env-dumping commands before other checks ──
261    // These expose all environment variables including secrets.
262    if is_env_dumping_command(cmd) {
263        return Some("environment variable dumping command is blocked");
264    }
265
266    // ── N-004: Fork bomb detection ──
267    // Fork bombs use shell function definitions that self-recurse via pipe.
268    // The canonical form is `:(){ :|:& };:` but variants exist with spaces
269    // and without semicolons. Detection requires both the function definition
270    // syntax `:(){` (or `:() {`) and the self-referencing pipe `:|:&` (or
271    // `:|: &`). The standalone `:` builtin is harmless — only the function
272    // definition pattern is dangerous.
273    {
274        let has_func_def = cmd_lower.contains(":(){")
275            || cmd_lower.contains(":(){ ")
276            || cmd_lower.contains(":() {")
277            || detok_lower.contains(":(){")
278            || detok_lower.contains(":(){ ")
279            || detok_lower.contains(":() {");
280        let has_self_pipe = cmd_lower.contains(":|:&")
281            || cmd_lower.contains(":|: &")
282            || detok_lower.contains(":|:&")
283            || detok_lower.contains(":|: &");
284        if has_func_def && has_self_pipe {
285            return Some("fork bomb pattern blocked");
286        }
287    }
288
289    // ── GAP-03: Heredoc/herestring detection ──
290    // << opens a heredoc; <<< is a herestring. Both allow multi-line
291    // content that bypasses single-line blocklist substring checks.
292    // The content following << could contain any dangerous command
293    // spread across multiple lines.
294    if cmd_lower.contains("<<") || detok_lower.contains("<<") {
295        return Some("heredoc/herestring (<<) is blocked — use inline commands");
296    }
297
298    // ── GAP-04: Process substitution detection ──
299    // <(cmd) creates an input named pipe; >(cmd) creates an output named pipe.
300    // Both allow command execution that bypasses path checks and blocklist
301    // substring checks. Block them entirely.
302    if cmd_lower.contains("<(")
303        || cmd_lower.contains(">(")
304        || detok_lower.contains("<(")
305        || detok_lower.contains(">(")
306    {
307        return Some("process substitution (<( ) or >( )) is blocked");
308    }
309
310    // ── GAP-05: Command substitution detection ──
311    // $(cmd) and `cmd` are command substitutions that execute at runtime.
312    // Static analysis cannot determine what they produce, so they must be
313    // blocked — a harmless-looking $(echo hello) could become dangerous
314    // through environment variables or argument injection.
315    if cmd.contains("$(")
316        || cmd.contains('`')
317        || detok_lower.contains("$(")
318        || detok_lower.contains('`')
319    {
320        return Some("command substitution ($( ) or backtick) is blocked");
321    }
322
323    // ── N-007: Block bare `rm` regardless of flags ──
324    // All `rm` usage is blocked. The shell provides `help` builtin for
325    // documentation. This catches `rm /tmp/file`, `rm -rf /`, `r"m" /tmp/x`,
326    // and all detokenized variants. Placed BEFORE the existing rm checks so
327    // that both the simple `rm` and the flag-variant checks are redundant
328    // but defense-in-depth.
329    if command_matches(&cmd_lower, &["rm"]) || command_matches(&detok_lower, &["rm"]) {
330        return Some("rm command blocked — use FileWrite/Undo capability");
331    }
332
333    // rm --no-preserve-root is always blocked — bypasses root safety guard
334    let rm_no_preserve = "rm".to_string() + " --no-preserve-root";
335    if (cmd_lower.contains("rm") && cmd_lower.contains("--no-preserve-root"))
336        || (detok_lower.contains("rm") && detok_lower.contains("--no-preserve-root"))
337        || detok_lower.contains(&rm_no_preserve)
338    {
339        return Some("rm --no-preserve-root is blocked");
340    }
341
342    // rm with recursive/destructive flags is always blocked
343    // Catches: rm -rf /, rm -fr /*, rm --recursive /, rm -r -f /, etc.
344    // F-013: Check both original and detokenized to catch r"m" -rf /
345    let rm_recursive_check = |s: &str| -> bool {
346        s.contains("rm")
347            && (s.contains("-rf")
348                || s.contains("-fr")
349                || s.contains("--recursive")
350                || s.contains(" -r ")
351                || s.contains(" -f "))
352    };
353
354    if rm_recursive_check(&cmd_lower) || rm_recursive_check(&detok_lower) {
355        return Some("recursive rm is blocked");
356    }
357
358    let mkfs_check = |s: &str| -> bool { s.contains("mkfs") || s.contains("mkswap") };
359    if mkfs_check(&cmd_lower) || mkfs_check(&detok_lower) {
360        return Some("filesystem creation commands are blocked");
361    }
362
363    let fdisk_check = |s: &str| -> bool { s.contains("fdisk") || s.contains("parted") };
364    if fdisk_check(&cmd_lower) || fdisk_check(&detok_lower) {
365        return Some("disk partitioning commands are blocked");
366    }
367
368    let dd_check = |s: &str| -> bool {
369        s.contains(" dd ")
370            || s.starts_with("dd ")
371            || s.ends_with(" dd")
372            || s.contains(" dd\t")
373            || s.starts_with("dd\t")
374    };
375    if dd_check(&cmd_lower) || dd_check(&detok_lower) {
376        return Some("dd (disk destroyer) is blocked");
377    }
378
379    // N-001: shred — secure file deletion (overwrites before unlinking)
380    // Blocks: `shred -u /tmp/file`, `shred -vfz /tmp/file`, etc.
381    // Same pattern as chown/chgrp — prefix-matched to avoid false positives
382    // on filenames containing "shred" as a substring.
383    if command_matches(&cmd_lower, &["shred"]) || command_matches(&detok_lower, &["shred"]) {
384        return Some("shred command blocked — use FileWrite/Undo capability");
385    }
386
387    let power_check = |s: &str| -> bool {
388        s.contains("shutdown") || s.contains("reboot") || s.contains("poweroff")
389    };
390    if power_check(&cmd_lower) || power_check(&detok_lower) {
391        return Some("system power commands are blocked");
392    }
393
394    // chown/chgrp — ownership changes (check both original and detokenized)
395    if command_matches(&cmd_lower, &["chown", "chgrp"])
396        || command_matches(&detok_lower, &["chown", "chgrp"])
397    {
398        return Some("ownership change commands are blocked");
399    }
400
401    // mount/umount — filesystem mount operations
402    if command_matches(&cmd_lower, &["mount", "umount"])
403        || command_matches(&detok_lower, &["mount", "umount"])
404    {
405        return Some("mount/unmount commands are blocked");
406    }
407
408    // iptables/nft — firewall manipulation
409    if command_matches(&cmd_lower, &["iptables", "nft"])
410        || command_matches(&detok_lower, &["iptables", "nft"])
411    {
412        return Some("firewall manipulation commands are blocked");
413    }
414
415    // N-005: kill — process termination
416    // Blocks: `kill -9 1234`, `killall`, `kill -TERM`, etc.
417    // Agents should use the Kill capability for process management, which
418    // enforces PID validation and audit trail. Shell-level kill bypasses
419    // those protections.
420    if command_matches(&cmd_lower, &["kill", "killall", "pkill"])
421        || command_matches(&detok_lower, &["kill", "killall", "pkill"])
422    {
423        return Some("kill command blocked — use Kill capability");
424    }
425
426    // rm -rf on system directories (check both)
427    let rm_system_check = |s: &str| -> bool {
428        s.contains("rm")
429            && (s.contains("-rf")
430                || s.contains("-fr")
431                || s.contains("--recursive")
432                || s.contains(" -r ")
433                || s.contains(" -f "))
434            && (s.contains(" / ")
435                || s.contains("/*")
436                || s.contains("/dev")
437                || s.contains("/boot")
438                || s.contains("/home")
439                || s.contains("/etc")
440                || s.contains("/usr")
441                || s.contains("/var")
442                || s.contains("/lib")
443                || s.contains("/opt")
444                || s.contains("/bin")
445                || s.contains("/sbin"))
446    };
447    if rm_system_check(&cmd_lower) || rm_system_check(&detok_lower) {
448        return Some("rm -rf / --recursive on system directories is blocked");
449    }
450
451    // rm with tilde expansion (check both)
452    let rm_tilde_check = |s: &str| -> bool {
453        s.contains("rm")
454            && (s.contains("-rf")
455                || s.contains("-fr")
456                || s.contains("--recursive")
457                || s.contains(" -r ")
458                || s.contains(" -f "))
459            && s.contains('~')
460    };
461    if rm_tilde_check(&cmd_lower) || rm_tilde_check(&detok_lower) {
462        return Some("rm with shell expansions is blocked — use explicit paths");
463    }
464
465    // N-019: chmod — permission changes (check both original and detokenized)
466    // Broadened from the previous triple-condition (chmod + 777 + " /") to
467    // block ALL chmod invocations. Agents should use FileWrite/Undo for
468    // permission management, which enforces path validation and audit trail.
469    if command_matches(&cmd_lower, &["chmod"]) || command_matches(&detok_lower, &["chmod"]) {
470        return Some("chmod command blocked — use FileWrite/Undo capability");
471    }
472
473    None
474}
475
476/// Tests whether a command invokes a network client.
477///
478/// Blocked tools: `curl`, `wget`, `nc`/`ncat`/`netcat`, `socat`,
479/// `ssh`, `scp`, `telnet`.
480///
481/// These are only blocked when `RUNTIMO_ENABLE_NETWORK` is not set to `"1"`.
482#[must_use]
483pub fn is_network_command(cmd: &str) -> bool {
484    let cmd_lower = cmd.to_lowercase();
485    command_matches(
486        &cmd_lower,
487        &[
488            "curl", "wget", "nc", "ncat", "netcat", "socat", "ssh", "scp", "telnet",
489        ],
490    )
491}
492
493/// Checks whether outbound network commands are permitted.
494///
495/// Returns `true` when network tools are allowed (env var set to `"1"`).
496#[must_use]
497pub fn network_enabled() -> bool {
498    std::env::var("RUNTIMO_ENABLE_NETWORK").as_deref() == Ok("1")
499}
500
501/// Tests whether a command invokes a scripting language interpreter.
502///
503/// Blocked tools: `python`, `python3`, `perl`, `ruby`, `node`, `lua`,
504/// `php`, `tclsh`, `wish`, `racket`, `guile`, `ghci`, `runghc`, `scala`,
505/// `gawk`, `nawk`.
506///
507/// Interpreters can execute arbitrary code including filesystem manipulation,
508/// network access, and process spawning — bypassing all blocklist protections.
509/// They are gated behind `RUNTIMO_ENABLE_INTERPRETERS=1`.
510#[must_use]
511pub fn is_interpreter_command(cmd: &str) -> bool {
512    let cmd_lower = cmd.to_lowercase();
513    command_matches(
514        &cmd_lower,
515        &[
516            "python", "python3", "python2", "perl", "ruby", "node", "lua", "php", "tclsh", "wish",
517            "racket", "guile", "ghci", "runghc", "scala", "gawk", "nawk",
518        ],
519    )
520}
521
522/// Checks whether interpreter commands are permitted.
523///
524/// Returns `true` when interpreters are allowed (env var set to `"1"`).
525/// Default is blocked — agents should use ShellExec for shell commands,
526/// not arbitrary interpreter invocations.
527#[must_use]
528pub fn interpreters_enabled() -> bool {
529    std::env::var("RUNTIMO_ENABLE_INTERPRETERS").as_deref() == Ok("1")
530}
531
532// ── F-013: Shell detokenizer ──────────────────────────────────────────────
533
534/// # Multi-pass detokenization (GAP-02)
535///
536/// This function runs detokenization repeatedly until the output stabilizes.
537/// This handles nested/complex quoting patterns that survive a single pass:
538/// - `r""m""` → pass 1: `rm` → stable (already handled by single pass)
539/// - Mixed ANSI-C + double-quote nesting: handled by repeat
540/// - Backslash-newline line continuations: removed in first pass
541///
542/// Each pass strips one layer of quoting; the loop terminates when no
543/// further changes are detected or after 16 passes (safety limit).
544#[must_use]
545pub fn detokenize_command(cmd: &str) -> String {
546    const MAX_PASSES: usize = 16;
547
548    let mut current = cmd.to_string();
549    let mut previous;
550    let mut passes: usize = 0;
551
552    loop {
553        previous = current.clone();
554        current = detokenize_single_pass(&previous);
555        passes = passes.saturating_add(1);
556        if current == previous || passes >= MAX_PASSES {
557            break;
558        }
559    }
560    current
561}
562
563/// Single-pass detokenizer — strips one layer of shell quoting.
564///
565/// See [`detokenize_command`] for the multi-pass wrapper.
566#[must_use]
567fn detokenize_single_pass(cmd: &str) -> String {
568    let mut result = String::with_capacity(cmd.len());
569    let mut chars = cmd.chars().peekable();
570
571    while let Some(&c) = chars.peek() {
572        match c {
573            '\\' => {
574                // Backslash escape: skip the backslash
575                chars.next(); // consume backslash
576                if let Some(next) = chars.next() {
577                    // GAP-02: backslash-newline is a line continuation —
578                    // both the backslash and newline are removed in POSIX sh.
579                    // Emit nothing so tokens on either side rejoin.
580                    if next != '\n' {
581                        result.push(next);
582                    }
583                }
584                // If backslash is last char, it just disappears
585            }
586            '$' => {
587                // Check for ANSI-C quoting: $'...' (GAP-01, GAP-06)
588                // Peek at the next character without consuming $
589                chars.next(); // consume $
590                if chars.peek() == Some(&'\'') {
591                    chars.next(); // consume the opening '
592                                  // Process ANSI-C quoted string until closing '
593                    while let Some(ch) = chars.next() {
594                        if ch == '\'' {
595                            break; // closing quote
596                        }
597                        if ch == '\\' {
598                            // ANSI-C escape sequence — expand to literal
599                            match chars.next() {
600                                Some('\\') => result.push('\\'),
601                                Some('\'') => result.push('\''),
602                                Some('"') => result.push('"'),
603                                Some('?') => result.push('?'),
604                                Some('a') => result.push('a'),
605                                Some('b') => result.push('b'),
606                                Some('f') => result.push('f'),
607                                Some('n' | 'r' | 't' | 'v') => {
608                                    // whitespace escapes → space for token visibility
609                                    result.push(' ');
610                                }
611                                Some('e' | 'E') => result.push('e'),
612                                // \xHH — hex escape (up to 2 hex digits)
613                                Some('x') => {
614                                    let mut hex = String::new();
615                                    for _ in 0..2 {
616                                        if let Some(&h) = chars.peek() {
617                                            if h.is_ascii_hexdigit() {
618                                                hex.push(h);
619                                                chars.next();
620                                            } else {
621                                                break;
622                                            }
623                                        }
624                                    }
625                                    if let Ok(byte) = u8::from_str_radix(&hex, 16) {
626                                        if let Some(c) = char::from_u32(u32::from(byte)) {
627                                            result.push(c);
628                                        }
629                                    }
630                                }
631                                // \uHHHH — 16-bit unicode escape
632                                Some('u') => {
633                                    let mut hex = String::new();
634                                    for _ in 0..4 {
635                                        if let Some(&h) = chars.peek() {
636                                            if h.is_ascii_hexdigit() {
637                                                hex.push(h);
638                                                chars.next();
639                                            } else {
640                                                break;
641                                            }
642                                        }
643                                    }
644                                    if let Ok(cp) = u32::from_str_radix(&hex, 16) {
645                                        if let Some(c) = char::from_u32(cp) {
646                                            result.push(c);
647                                        }
648                                    }
649                                }
650                                // \UHHHHHHHH — 32-bit unicode escape
651                                Some('U') => {
652                                    let mut hex = String::new();
653                                    for _ in 0..8 {
654                                        if let Some(&h) = chars.peek() {
655                                            if h.is_ascii_hexdigit() {
656                                                hex.push(h);
657                                                chars.next();
658                                            } else {
659                                                break;
660                                            }
661                                        }
662                                    }
663                                    if let Ok(cp) = u32::from_str_radix(&hex, 16) {
664                                        if let Some(c) = char::from_u32(cp) {
665                                            result.push(c);
666                                        }
667                                    }
668                                }
669                                // \NNN — octal escape (1-3 octal digits)
670                                Some(d) if ('0'..='7').contains(&d) => {
671                                    let mut octal = String::from(d);
672                                    for _ in 0..2 {
673                                        if let Some(&od) = chars.peek() {
674                                            if ('0'..='7').contains(&od) {
675                                                octal.push(od);
676                                                chars.next();
677                                            } else {
678                                                break;
679                                            }
680                                        }
681                                    }
682                                    if let Ok(byte) = u8::from_str_radix(&octal, 8) {
683                                        if let Some(c) = char::from_u32(u32::from(byte)) {
684                                            result.push(c);
685                                        }
686                                    }
687                                }
688                                // \cX — control character (output X for visibility)
689                                Some(ctrl) => result.push(ctrl),
690                                // lone backslash at end of string: drop it
691                                None => {}
692                            }
693                        } else {
694                            result.push(ch);
695                        }
696                    }
697                } else {
698                    // Not ANSI-C quoting, emit $ as regular character
699                    result.push('$');
700                }
701            }
702            '\'' => {
703                // Single-quoted string: everything literal until closing '
704                chars.next(); // consume opening quote
705                for ch in chars.by_ref() {
706                    if ch == '\'' {
707                        break; // closing quote consumed, don't emit
708                    }
709                    result.push(ch);
710                }
711            }
712            '"' => {
713                // Double-quoted string: backslash escapes for "$`\ and newline
714                chars.next(); // consume opening quote
715                while let Some(ch) = chars.next() {
716                    if ch == '"' {
717                        break; // closing quote
718                    }
719                    if ch == '\\' {
720                        if let Some(&next_ch) = chars.peek() {
721                            match next_ch {
722                                '"' | '$' | '`' | '\\' | '\n' => {
723                                    chars.next(); // consume backslash
724                                    result.push(next_ch);
725                                    continue;
726                                }
727                                _ => {
728                                    // Not a special escape, keep the backslash
729                                    result.push('\\');
730                                    // next_ch will be handled by next iteration
731                                    continue;
732                                }
733                            }
734                        }
735                        // backslash at end of input, keep it
736                        result.push('\\');
737                        break;
738                    }
739                    result.push(ch);
740                }
741            }
742            _ => {
743                result.push(c);
744                chars.next(); // consume
745            }
746        }
747    }
748    result
749}
750
751// ── F-015: Environment sanitization ────────────────────────────────────────
752
753/// Returns `true` when the given environment variable name carries sensitive
754/// data and should be stripped before shell execution.
755///
756/// Checks both prefix-based patterns (e.g. `AWS_*`) and suffix-based patterns
757/// (e.g. `*_KEY`, `*_TOKEN`). The safe-list of known-safe variables overrides
758/// the blocklist.
759#[must_use]
760fn is_sensitive_env_var(key: &str) -> bool {
761    // Safe-list check first — explicit allowance overrides blocklist
762    if SAFE_ENV_VARS.contains(&key) {
763        return false;
764    }
765    let key_upper = key.to_uppercase();
766    // Prefix match
767    if SENSITIVE_ENV_PREFIXES
768        .iter()
769        .any(|prefix| key_upper.starts_with(prefix))
770    {
771        return true;
772    }
773    // Suffix match (e.g. MYAPP_API_KEY → matches _KEY)
774    SENSITIVE_ENV_SUFFIXES
775        .iter()
776        .any(|suffix| key_upper.ends_with(suffix))
777}
778
779/// Builds a sanitized environment for shell execution.
780///
781/// Strips environment variables matching sensitive patterns (prefix-based
782/// and suffix-based). Preserves safe system variables and Runtimo's own
783/// opt-in feature flags. PATH is always set to the sanitized path
784/// regardless of the incoming environment.
785#[must_use]
786fn sanitized_env() -> Vec<(String, String)> {
787    std::env::vars()
788        .filter(|(key, _)| !is_sensitive_env_var(key))
789        .collect()
790}
791
792/// Returns `true` when a command dumps all environment variables.
793///
794/// Shell builtins that expose the full environment (possibly including
795/// secrets) are flagged as dangerous. This catches:
796/// - `env` / `printenv` — print all env vars
797/// - `set` — shell builtin that dumps vars+functions
798/// - `export` / `export -p` — print exported vars
799/// - `declare -p` / `typeset -p` — bash/ksh var dump
800/// - `compgen -v` — bash variable name completion
801#[must_use]
802pub fn is_env_dumping_command(cmd: &str) -> bool {
803    let cmd_lower = cmd.to_lowercase().trim().to_string();
804    // Also check the detokenized version
805    let detok_lower = detokenize_command(&cmd_lower);
806
807    let env_dumpers: &[&str] = &[
808        "env", "printenv", "set", "export", "declare", "typeset", "compgen",
809    ];
810
811    for dumper in env_dumpers {
812        // Check original command
813        if command_matches(&cmd_lower, &[dumper]) {
814            return true;
815        }
816        // Check detokenized version (catches "e'n'v" etc.)
817        if command_matches(&detok_lower, &[dumper]) {
818            return true;
819        }
820    }
821    false
822}
823
824// ── F-014: Path-aware ShellExec ────────────────────────────────────────────
825
826/// Checks whether a resolved path is within one of the allowed prefixes.
827///
828/// Uses the same prefix-matching logic as [`crate::validation::path::path_in_prefix`]
829/// but operates on string paths without requiring filesystem existence.
830///
831/// Empty prefixes are skipped to prevent matching everything via
832/// `format!("{}/", "")` which produces `"/"` — a path that matches
833/// every absolute path (N-014 defense-in-depth).
834#[must_use]
835fn is_path_within_allowed(path_str: &str, allowed: &[String]) -> bool {
836    allowed
837        .iter()
838        .filter(|prefix| !prefix.is_empty())
839        .any(|prefix| path_str == prefix || path_str.starts_with(&format!("{}/", prefix)))
840}
841
842/// Expands `$VAR` and `${VAR}` references in a path-like token using the
843/// current process environment. Returns the expanded path, or the original
844/// token if no variables were found or expansion failed.
845///
846/// # GAP-08
847///
848/// This catches paths like `$HOME/.ssh/id_ed25519` and `${HOME}/.aws/credentials`
849/// that would otherwise be skipped by path validation because they don't start
850/// with `/` or `~/`.
851#[must_use]
852fn expand_shell_vars(token: &str) -> String {
853    let mut result = String::with_capacity(token.len());
854    let mut chars = token.chars().peekable();
855    let mut expanded_any = false;
856
857    while let Some(&c) = chars.peek() {
858        if c == '$' {
859            chars.next(); // consume $
860                          // Check for ${VAR} syntax
861            if chars.peek() == Some(&'{') {
862                chars.next(); // consume {
863                let mut var_name = String::new();
864                while let Some(&ch) = chars.peek() {
865                    if ch == '}' {
866                        chars.next(); // consume }
867                        break;
868                    }
869                    var_name.push(ch);
870                    chars.next();
871                }
872                // Expand the variable
873                if let Ok(value) = std::env::var(&var_name) {
874                    result.push_str(&value);
875                    expanded_any = true;
876                } else {
877                    // Variable not set — keep the reference as-is for blocklist
878                    result.push('$');
879                    result.push('{');
880                    result.push_str(&var_name);
881                    result.push('}');
882                }
883            } else {
884                // $VAR syntax (no braces) — read until non-alphanumeric or _
885                let mut var_name = String::new();
886                while let Some(&ch) = chars.peek() {
887                    if ch.is_alphanumeric() || ch == '_' {
888                        var_name.push(ch);
889                        chars.next();
890                    } else {
891                        break;
892                    }
893                }
894                if var_name.is_empty() {
895                    // Lone $ (e.g., end of string or $ followed by non-name char)
896                    result.push('$');
897                } else if let Ok(value) = std::env::var(&var_name) {
898                    result.push_str(&value);
899                    expanded_any = true;
900                } else {
901                    // Variable not set — keep reference
902                    result.push('$');
903                    result.push_str(&var_name);
904                }
905            }
906        } else {
907            result.push(c);
908            chars.next();
909        }
910    }
911
912    if expanded_any {
913        result
914    } else {
915        token.to_string()
916    }
917}
918
919/// Scans a shell command for path references and validates them against
920/// the allowed prefixes from configuration.
921///
922/// # What it catches
923///
924/// - Absolute paths: `cat /etc/passwd`, `ls /root/.ssh/`
925/// - Tilde-expanded paths: `cat ~/.ssh/id_ed25519`, `ls ~/.aws/credentials`
926/// - Paths after I/O redirects: `> /etc/cron.d/evil`, `< /root/secrets`
927///
928/// # What it does NOT catch (GAP-09, GAP-10)
929///
930/// - Command substitution paths: `cat $(find /etc -name shadow)` — blocked by GAP-05
931/// - Backtick paths: `` cat `/etc/passwd` `` — blocked by GAP-05
932/// - Inline redirect paths: `echo evil >/etc/cron.d/backdoor` — handled by GAP-10
933///
934/// These are documented as GAP-09 through GAP-12 and require a shell-AST
935/// based approach for comprehensive coverage.
936///
937/// # Returns
938///
939/// `None` if all detectable paths are within allowed directories.
940/// `Some(reason)` with a human-readable description of the blocked path.
941#[must_use]
942fn check_command_paths(cmd: &str) -> Option<String> {
943    let allowed = RuntimoConfig::get_allowed_prefixes();
944    let detok = detokenize_command(cmd);
945
946    // Scan each whitespace-separated token
947    for token in detok.split_whitespace() {
948        // Strip surrounding quotes, backticks, commas (common in arg lists)
949        let path = token.trim_matches(|c: char| c == '"' || c == '\'' || c == '`' || c == ',');
950
951        // Skip empty or non-path tokens
952        if path.is_empty() || path.len() < 2 {
953            continue;
954        }
955
956        // Skip "--flag" style arguments
957        if path.starts_with('-') {
958            continue;
959        }
960
961        // ── GAP-10: Strip I/O redirect operators ──
962        // Tokens like ">/etc/cron.d/evil" have the redirect operator
963        // glued to the path. Strip the operator prefix and check the
964        // remaining path. Handles: > >> < 2> 1> &> 2>> &>>
965        let path_without_redirect = path
966            .trim_start_matches("&>>")
967            .trim_start_matches("2>>")
968            .trim_start_matches("1>>")
969            .trim_start_matches("&>")
970            .trim_start_matches("2>")
971            .trim_start_matches("1>")
972            .trim_start_matches(">>")
973            .trim_start_matches('>')
974            .trim_start_matches('<');
975
976        // If we stripped something and the remainder starts with /, use it
977        let path = if path_without_redirect != path
978            && (path_without_redirect.starts_with('/') || path_without_redirect.starts_with("~/"))
979        {
980            path_without_redirect
981        } else {
982            path
983        };
984
985        // Skip shell variable assignments: VAR=value
986        if path.contains('=') && !path.starts_with('/') && !path.starts_with("~/") {
987            continue;
988        }
989
990        // ── GAP-08: Expand $VAR and ${VAR} references ──
991        // Shell variables like $HOME/.ssh/id_ed25519 don't start with /
992        // or ~/, so they'd normally be skipped. Expand them against the
993        // current environment and check the resolved path.
994        let resolved = if path.contains('$') {
995            let expanded = expand_shell_vars(path);
996            if expanded == path {
997                // No variables were expanded; token might just contain
998                // a literal $ that isn't a var reference. Skip.
999                continue;
1000            }
1001            // After expansion, check if the resolved path is absolute
1002            if expanded.starts_with('/') {
1003                // Strip trailing shell operators
1004                let clean = expanded.trim_end_matches(|c: char| {
1005                    c == ';' || c == '|' || c == '&' || c == '>' || c == '<'
1006                });
1007                clean.to_string()
1008            } else {
1009                // Expanded but not absolute — skip (e.g., echo $VAR)
1010                continue;
1011            }
1012        } else if path.starts_with("~/") {
1013            match std::env::var("HOME") {
1014                Ok(home) => {
1015                    let mut home_path = home.trim_end_matches('/').to_string();
1016                    home_path.push_str(&path[1..]); // path[1..] = "/rest/of/path"
1017                    home_path
1018                }
1019                Err(_) => continue, // skip if HOME is unset (rare)
1020            }
1021        } else if path.starts_with('/') {
1022            // Strip trailing shell operators: semicolon, pipe, redirect, ampersand
1023            // e.g. "/etc/passwd;" → "/etc/passwd"
1024            let clean_end = path.trim_end_matches(|c: char| {
1025                c == ';' || c == '|' || c == '&' || c == '>' || c == '<'
1026            });
1027            clean_end.to_string()
1028        } else if path.starts_with('.') {
1029            // ── GAP-13: Resolve relative paths against CWD ──
1030            // `../../etc/passwd` from /tmp resolves to /etc/passwd.
1031            // Normalize the path (join with CWD, resolve ".." and ".")
1032            // without requiring filesystem existence.
1033            let Ok(cwd) = std::env::current_dir() else {
1034                continue;
1035            };
1036            let joined = cwd.join(path);
1037            // Normalize: remove "." and resolve ".." components
1038            let mut components: Vec<&str> = Vec::new();
1039            for component in joined.components() {
1040                match component {
1041                    std::path::Component::ParentDir => {
1042                        components.pop(); // go up one level
1043                    }
1044                    std::path::Component::Normal(os_str) => {
1045                        if let Some(s) = os_str.to_str() {
1046                            components.push(s);
1047                        }
1048                    }
1049                    std::path::Component::RootDir => {
1050                        // Start fresh from root
1051                        components.clear();
1052                    }
1053                    // CurDir, Prefix — no effect
1054                    _ => {}
1055                }
1056            }
1057            let normalized = format!("/{}", components.join("/"));
1058            // Reject if the normalized path contains ".." (couldn't fully resolve)
1059            if normalized.contains("/../") || normalized.contains("/..") || normalized == "/.." {
1060                return Some(format!(
1061                    "ShellExec blocked: path traversal not allowed: {}",
1062                    path
1063                ));
1064            }
1065            normalized
1066        } else {
1067            continue;
1068        };
1069
1070        // Skip if it's just "/" (root — handled by the blocklist)
1071        if resolved == "/" {
1072            continue;
1073        }
1074
1075        // Reject paths containing ".." traversal (prevents prefix-bypass
1076        // via parent traversal: /home/user/../../etc/passwd)
1077        if resolved.contains("/../") || resolved.contains("/..") || resolved == ".." {
1078            return Some(format!(
1079                "ShellExec blocked: path traversal not allowed: {}",
1080                path
1081            ));
1082        }
1083
1084        // Skip device paths (handled by blocklist: dd, mkfs)
1085        if resolved.starts_with("/dev/") {
1086            continue;
1087        }
1088
1089        // Skip proc/sys filesystem (handled by blocklist)
1090        if resolved.starts_with("/proc/") || resolved.starts_with("/sys/") {
1091            continue;
1092        }
1093
1094        if !is_path_within_allowed(&resolved, &allowed) {
1095            // Don't leak the actual resolved HOME path in error messages
1096            let display_path = if path.starts_with("~/") {
1097                path.to_string()
1098            } else {
1099                resolved
1100            };
1101            return Some(format!(
1102                "ShellExec blocked: path is outside allowed directories: {}",
1103                display_path
1104            ));
1105        }
1106    }
1107
1108    None
1109}
1110
1111#[allow(clippy::arithmetic_side_effects)] // -(pgid) negation is safe for valid PIDs
1112fn wait_with_timeout(child: &mut Child, pgid: u32, timeout_secs: u64) -> WaitResult {
1113    let start = Instant::now();
1114    let timeout = Duration::from_secs(timeout_secs);
1115    let child_pid = child.id();
1116    let stdout_thread = child.stdout.take().map(|stdout| {
1117        thread::spawn(move || {
1118            let mut data = Vec::new();
1119            let _ = stdout.take(MAX_OUTPUT_BYTES as u64).read_to_end(&mut data);
1120            data
1121        })
1122    });
1123    let stderr_thread = child.stderr.take().map(|stderr| {
1124        thread::spawn(move || {
1125            let mut data = Vec::new();
1126            let _ = stderr.take(MAX_OUTPUT_BYTES as u64).read_to_end(&mut data);
1127            data
1128        })
1129    });
1130    let mut last_descendants: Vec<u32>;
1131    loop {
1132        if start.elapsed() > timeout {
1133            // SAFETY: pgid is a valid process group ID from the spawned child; SIGKILL is well-defined;
1134            // pgid as pid_t may wrap on 32-bit but pgid is always within pid_t range
1135            #[allow(clippy::cast_possible_wrap)]
1136            unsafe {
1137                let _ = libc::kill(-(pgid as libc::pid_t), libc::SIGKILL);
1138            }
1139            let killed_descendants = get_all_descendants(child_pid);
1140            let _ = child.wait();
1141            let _ = stdout_thread.map(|h| h.join().unwrap_or_default());
1142            let _ = stderr_thread.map(|h| h.join().unwrap_or_default());
1143            return Err(Error::ExecutionFailed(format!(
1144                "command timed out after {}s (killed {} descendants)",
1145                timeout_secs,
1146                killed_descendants.len()
1147            )));
1148        }
1149        last_descendants = get_all_descendants(child_pid);
1150        match child.try_wait() {
1151            Ok(Some(status)) => {
1152                let stdout_data = stdout_thread
1153                    .map(|h| h.join().unwrap_or_default())
1154                    .unwrap_or_default();
1155                let stderr_data = stderr_thread
1156                    .map(|h| h.join().unwrap_or_default())
1157                    .unwrap_or_default();
1158                return Ok((status, stdout_data, stderr_data, last_descendants));
1159            }
1160            Ok(None) => std::thread::sleep(Duration::from_millis(50)),
1161            Err(e) => return Err(Error::ExecutionFailed(format!("error waiting: {}", e))),
1162        }
1163    }
1164}
1165
1166fn get_direct_children(pid: u32) -> Vec<u32> {
1167    let children_path = format!("/proc/{}/children", pid);
1168    if let Ok(content) = fs::read_to_string(&children_path) {
1169        content
1170            .split_whitespace()
1171            .filter_map(|s| s.parse::<u32>().ok())
1172            .collect()
1173    } else {
1174        Vec::new()
1175    }
1176}
1177
1178fn get_all_descendants(pid: u32) -> Vec<u32> {
1179    let mut descendants = Vec::new();
1180    let mut stack = vec![pid];
1181    let mut visited = std::collections::HashSet::new();
1182    while let Some(current) = stack.pop() {
1183        if visited.contains(&current) {
1184            continue;
1185        }
1186        visited.insert(current);
1187        let children = get_direct_children(current);
1188        if children.is_empty() {
1189            if let Ok(output) = std::process::Command::new("pgrep")
1190                .arg("-P")
1191                .arg(current.to_string())
1192                .output()
1193            {
1194                if output.status.success() {
1195                    let pgrep_lines = String::from_utf8_lossy(&output.stdout).to_string();
1196                    let pgrep_children = pgrep_lines
1197                        .lines()
1198                        .filter_map(|s| s.trim().parse::<u32>().ok());
1199                    for child in pgrep_children {
1200                        if !visited.contains(&child) {
1201                            descendants.push(child);
1202                            stack.push(child);
1203                        }
1204                    }
1205                    continue;
1206                }
1207            }
1208        }
1209        for child in children {
1210            if !visited.contains(&child) {
1211                descendants.push(child);
1212                stack.push(child);
1213            }
1214        }
1215    }
1216    descendants
1217}
1218
1219/// Capability that executes shell commands with safety guards.
1220///
1221/// Commands are run in the executor's process group with a configurable
1222/// timeout. A blocklist rejects destructive commands (e.g. `rm -rf /`,
1223/// `dd if=/dev/zero of=/dev/sda`). All executions are logged to the WAL.
1224#[allow(clippy::exhaustive_structs)]
1225pub struct ShellExec;
1226
1227impl TypedCapability for ShellExec {
1228    type Args = ShellExecArgs;
1229
1230    fn name(&self) -> &'static str {
1231        "ShellExec"
1232    }
1233    fn description(&self) -> &'static str {
1234        "execute shell command via sh -c with timeout, audit trail, detokenized blocklist, path restrictions, env sanitization, and PID tracking. blocks: rm, shred, mkfs, fdisk, dd, shutdown, chown, chmod, kill, mount, iptables, interpreters (opt-in), network tools (opt-in), fork bombs, env dumpers."
1235    }
1236    fn schema(&self) -> Value {
1237        serde_json::json!({
1238            "type": "object",
1239            "properties": {
1240                "cmd": { "type": "string", "description": "Command to execute via sh -c" },
1241                "timeout_secs": { "type": "integer", "minimum": 1, "maximum": 300 },
1242                "cwd": { "type": "string" },
1243                "stdin": { "type": "string" }
1244            },
1245            "required": ["cmd"]
1246        })
1247    }
1248    fn execute(
1249        &self,
1250        args: ShellExecArgs,
1251        ctx: &Context,
1252    ) -> std::result::Result<Output, CapabilityError> {
1253        // Timeout from JSON args, falling back to default
1254        let timeout = args.timeout_secs.unwrap_or(DEFAULT_TIMEOUT_SECS);
1255
1256        // F-013: Blocklist check (original + detokenized) — runs BEFORE dry_run
1257        // so dangerous commands are rejected even in dry-run mode (F-017).
1258        if let Some(reason) = is_dangerous_command(&args.cmd) {
1259            return Err(CapabilityError::PermissionDenied(format!(
1260                "dangerous command blocked: {}",
1261                reason
1262            )));
1263        }
1264
1265        // F-015: Block env-dumping commands (checked in is_dangerous_command
1266        // but also here as defense-in-depth in case the blocklist evolves)
1267        if !network_enabled() && is_network_command(&args.cmd) {
1268            return Err(CapabilityError::PermissionDenied(
1269                "network commands blocked — set RUNTIMO_ENABLE_NETWORK=1 to enable".into(),
1270            ));
1271        }
1272
1273        // N-009: Block interpreter commands (checked in is_dangerous_command
1274        // would be redundant — interpreters are not "dangerous" in the blocklist
1275        // sense, they are gated capabilities. This is the gating layer.)
1276        if !interpreters_enabled() && is_interpreter_command(&args.cmd) {
1277            return Err(CapabilityError::PermissionDenied(
1278                "interpreter commands blocked — set RUNTIMO_ENABLE_INTERPRETERS=1 to enable".into(),
1279            ));
1280        }
1281
1282        // F-014: Path restriction check — scan for paths outside allowed prefixes
1283        if let Some(reason) = check_command_paths(&args.cmd) {
1284            return Err(CapabilityError::PermissionDenied(reason));
1285        }
1286
1287        // Dry-run check AFTER security validation — ensures dangerous commands
1288        // are blocked even in dry-run mode (F-017: dry_run must not bypass security)
1289        if ctx.dry_run {
1290            let mut out = Output::ok("DRY RUN".into());
1291            out.data = Some(serde_json::json!({ "cmd": &args.cmd, "dry_run": true }));
1292            return Ok(out);
1293        }
1294
1295        let mut cmd = Command::new("sh");
1296        // PATH sanitization: limit executable resolution to trusted system dirs.
1297        // This is defense-in-depth — the blocklist catches known-dangerous
1298        // commands, but this prevents invocation of custom binaries in
1299        // non-standard locations.
1300        cmd.env("PATH", "/usr/local/bin:/usr/bin:/bin");
1301
1302        // F-015: Sanitized environment — strip sensitive env vars before spawning.
1303        // Only safe system variables survive; secrets (API keys, tokens, passwords)
1304        // are removed. RUNTIMO_ENABLE_NETWORK is explicitly preserved in the safe list.
1305        let safe_env = sanitized_env();
1306        for (key, value) in &safe_env {
1307            // PATH is set explicitly above; skip to avoid duplicate
1308            if key == "PATH" {
1309                continue;
1310            }
1311            cmd.env(key, value);
1312        }
1313
1314        cmd.arg("-c").arg(&args.cmd);
1315        if let Some(cwd) = &args.cwd {
1316            let path_ctx = PathContext {
1317                require_exists: true,
1318                require_file: false,
1319                ..Default::default()
1320            };
1321            let cwd_path = validate_path(cwd, &path_ctx)
1322                .map_err(|e| CapabilityError::PermissionDenied(format!("invalid cwd: {}", e)))?;
1323            cmd.current_dir(cwd_path);
1324        }
1325        let mut child = cmd
1326            .process_group(0)
1327            .stdout(std::process::Stdio::piped())
1328            .stderr(std::process::Stdio::piped())
1329            .stdin(if args.stdin.is_some() {
1330                std::process::Stdio::piped()
1331            } else {
1332                std::process::Stdio::null()
1333            })
1334            .spawn()
1335            .map_err(|e| {
1336                CapabilityError::Io(std::io::Error::other(format!("failed to spawn: {}", e)))
1337            })?;
1338        let child_pid = child.id();
1339        let pgid = child_pid;
1340        if let Some(ref stdin_content) = args.stdin {
1341            if stdin_content.len() > MAX_STDIN_BYTES {
1342                return Err(CapabilityError::InvalidArgs("stdin too large".into()));
1343            }
1344            if let Some(mut stdin_pipe) = child.stdin.take() {
1345                let _ = stdin_pipe.write_all(stdin_content.as_bytes());
1346            }
1347        }
1348        let (exit_status, stdout, stderr, _descendants) =
1349            wait_with_timeout(&mut child, pgid, timeout)
1350                .map_err(|e| CapabilityError::Internal(e.to_string()))?;
1351        let stdout_str = String::from_utf8_lossy(&stdout).to_string();
1352        let stderr_str = String::from_utf8_lossy(&stderr).to_string();
1353        let success = exit_status.success();
1354
1355        let mut out = if success {
1356            Output::ok("completed".into())
1357        } else {
1358            Output::error(
1359                format!("exit code {}", exit_status.code().unwrap_or(-1)),
1360                format!("exit code {}", exit_status.code().unwrap_or(-1)),
1361            )
1362        };
1363        out.data = Some(
1364            serde_json::json!({ "cmd": &args.cmd, "stdout": stdout_str, "stderr": stderr_str, "exit_code": exit_status.code().unwrap_or(-1), "pid": child_pid, "timeout_secs": timeout, "timed_out": exit_status.code().is_none(), "truncated": stdout.len() >= MAX_OUTPUT_BYTES || stderr.len() >= MAX_OUTPUT_BYTES }),
1365        );
1366        Ok(out)
1367    }
1368}
1369
1370#[cfg(test)]
1371mod tests {
1372    use super::*;
1373    use crate::capability::Capability;
1374    use std::time::Instant;
1375    #[test]
1376    fn executes_uptime() {
1377        let r = Capability::execute(
1378            &ShellExec,
1379            &serde_json::json!({"cmd": "uptime"}),
1380            &Context {
1381                dry_run: false,
1382                job_id: "test".into(),
1383                working_dir: std::env::temp_dir(),
1384            },
1385        )
1386        .unwrap();
1387        assert_eq!(r.status, "ok");
1388    }
1389    #[test]
1390    fn pipes_work() {
1391        let r = Capability::execute(
1392            &ShellExec,
1393            &serde_json::json!({"cmd": "echo hi | cat"}),
1394            &Context {
1395                dry_run: false,
1396                job_id: "test".into(),
1397                working_dir: std::env::temp_dir(),
1398            },
1399        )
1400        .unwrap();
1401        assert_eq!(r.status, "ok");
1402        assert!(r.data.as_ref().unwrap()["stdout"]
1403            .as_str()
1404            .unwrap()
1405            .contains("hi"));
1406    }
1407    #[test]
1408    fn chaining_works() {
1409        let r = Capability::execute(
1410            &ShellExec,
1411            &serde_json::json!({"cmd": "echo a && echo b"}),
1412            &Context {
1413                dry_run: false,
1414                job_id: "test".into(),
1415                working_dir: std::env::temp_dir(),
1416            },
1417        )
1418        .unwrap();
1419        assert_eq!(r.status, "ok");
1420    }
1421    #[test]
1422    fn blocks_dangerous() {
1423        assert!(Capability::execute(
1424            &ShellExec,
1425            &serde_json::json!({"cmd": "mkfs"}),
1426            &Context {
1427                dry_run: false,
1428                job_id: "test".into(),
1429                working_dir: std::env::temp_dir(),
1430            }
1431        )
1432        .is_err());
1433    }
1434    #[test]
1435    fn blocks_recursive_flag() {
1436        // rm --recursive (long form) should be caught like -rf
1437        assert!(Capability::execute(
1438            &ShellExec,
1439            &serde_json::json!({"cmd": "rm --recursive /home"}),
1440            &Context {
1441                dry_run: false,
1442                job_id: "test".into(),
1443                working_dir: std::env::temp_dir(),
1444            }
1445        )
1446        .is_err());
1447    }
1448    #[test]
1449    fn blocks_rm_rf_root() {
1450        // rm -rf / should always be blocked regardless of context
1451        assert!(Capability::execute(
1452            &ShellExec,
1453            &serde_json::json!({"cmd": "rm -rf /"}),
1454            &Context {
1455                dry_run: false,
1456                job_id: "test".into(),
1457                working_dir: std::env::temp_dir(),
1458            }
1459        )
1460        .is_err());
1461    }
1462    #[test]
1463    fn blocks_rm_no_preserve_root() {
1464        assert!(Capability::execute(
1465            &ShellExec,
1466            &serde_json::json!({"cmd": "rm --no-preserve-root -rf /"}),
1467            &Context {
1468                dry_run: false,
1469                job_id: "test".into(),
1470                working_dir: std::env::temp_dir(),
1471            }
1472        )
1473        .is_err());
1474    }
1475    #[test]
1476    fn blocks_ownership_commands() {
1477        for cmd in &["chown root /tmp/x", "chgrp staff /tmp/x"] {
1478            assert!(
1479                Capability::execute(
1480                    &ShellExec,
1481                    &serde_json::json!({"cmd": cmd}),
1482                    &Context {
1483                        dry_run: false,
1484                        job_id: "test".into(),
1485                        working_dir: std::env::temp_dir(),
1486                    }
1487                )
1488                .is_err(),
1489                "should block: {}",
1490                cmd
1491            );
1492        }
1493    }
1494    #[test]
1495    fn blocks_mount_commands() {
1496        for cmd in &["mount /dev/sda1 /mnt", "umount /mnt"] {
1497            assert!(
1498                Capability::execute(
1499                    &ShellExec,
1500                    &serde_json::json!({"cmd": cmd}),
1501                    &Context {
1502                        dry_run: false,
1503                        job_id: "test".into(),
1504                        working_dir: std::env::temp_dir(),
1505                    }
1506                )
1507                .is_err(),
1508                "should block: {}",
1509                cmd
1510            );
1511        }
1512    }
1513    #[test]
1514    fn blocks_firewall_commands() {
1515        for cmd in &["iptables -L", "nft list ruleset"] {
1516            assert!(
1517                Capability::execute(
1518                    &ShellExec,
1519                    &serde_json::json!({"cmd": cmd}),
1520                    &Context {
1521                        dry_run: false,
1522                        job_id: "test".into(),
1523                        working_dir: std::env::temp_dir(),
1524                    }
1525                )
1526                .is_err(),
1527                "should block: {}",
1528                cmd
1529            );
1530        }
1531    }
1532    #[test]
1533    fn blocks_network_commands_by_default() {
1534        // Ensure RUNTIMO_ENABLE_NETWORK is not set
1535        std::env::remove_var("RUNTIMO_ENABLE_NETWORK");
1536        for cmd in &[
1537            "curl http://example.com",
1538            "wget http://example.com",
1539            "nc example.com 80",
1540        ] {
1541            assert!(
1542                Capability::execute(
1543                    &ShellExec,
1544                    &serde_json::json!({"cmd": cmd}),
1545                    &Context {
1546                        dry_run: false,
1547                        job_id: "test".into(),
1548                        working_dir: std::env::temp_dir(),
1549                    }
1550                )
1551                .is_err(),
1552                "should block network cmd: {}",
1553                cmd
1554            );
1555        }
1556    }
1557    #[test]
1558    fn allows_network_commands_when_enabled() {
1559        std::env::set_var("RUNTIMO_ENABLE_NETWORK", "1");
1560        // curl --version should work (non-destructive)
1561        let r = Capability::execute(
1562            &ShellExec,
1563            &serde_json::json!({"cmd": "curl --version"}),
1564            &Context {
1565                dry_run: false,
1566                job_id: "test".into(),
1567                working_dir: std::env::temp_dir(),
1568            },
1569        );
1570        std::env::remove_var("RUNTIMO_ENABLE_NETWORK");
1571        // May fail if curl not installed, but should NOT fail with "network commands blocked"
1572        match r {
1573            Ok(o) => assert_eq!(o.status, "ok", "curl --version should succeed when enabled"),
1574            Err(e) => {
1575                let msg = e.to_string();
1576                assert!(
1577                    !msg.contains("network commands blocked"),
1578                    "should NOT block network when RUNTIMO_ENABLE_NETWORK=1, got: {}",
1579                    msg
1580                );
1581            }
1582        }
1583    }
1584    #[test]
1585    fn enforces_timeout() {
1586        let s = Instant::now();
1587        assert!(Capability::execute(
1588            &ShellExec,
1589            &serde_json::json!({"cmd": "sleep 5", "timeout_secs": 1}),
1590            &Context {
1591                dry_run: false,
1592                job_id: "test".into(),
1593                working_dir: std::env::temp_dir(),
1594            }
1595        )
1596        .is_err());
1597        assert!(s.elapsed().as_secs() < 3);
1598    }
1599
1600    // ── F-013: Detokenizer + Shell Quoting Bypass Tests ─────────────────
1601
1602    // ── GAP-01: ANSI-C quoting ($'...') tests ──────────────────────────
1603
1604    #[test]
1605    fn detokenize_ansi_c_tab_expansion() {
1606        // $'rm\t-rf\t/' → ANSI-C expands \t to tab, which we convert to space
1607        let detok = detokenize_command("$'rm\\t-rf\\t/'");
1608        // After ANSI-C expansion: rm -rf / (with spaces where tabs were)
1609        assert!(detok.contains("rm"));
1610        assert!(detok.contains("-rf"));
1611        assert!(detok.contains('/'));
1612    }
1613
1614    #[test]
1615    fn detokenize_ansi_c_plain_content() {
1616        // $'rm -rf /' → rm -rf / (plain content, no escapes)
1617        let detok = detokenize_command("$'rm -rf /'");
1618        assert!(detok.contains("rm -rf /"));
1619    }
1620
1621    #[test]
1622    fn detokenize_ansi_c_hex_escape() {
1623        // $'\x72\x6d' → rm (hex escape for each character)
1624        let detok = detokenize_command("$'\\x72\\x6d'");
1625        assert!(
1626            detok.contains("rm"),
1627            "Hex-encoded 'rm' should decode, got: {:?}",
1628            detok
1629        );
1630    }
1631
1632    #[test]
1633    fn detokenize_ansi_c_unicode_escape() {
1634        // $'\u0072\u006d' → rm (unicode escape for each character)
1635        let detok = detokenize_command("$'\\u0072\\u006d'");
1636        assert!(
1637            detok.contains("rm"),
1638            "Unicode-encoded 'rm' should decode, got: {:?}",
1639            detok
1640        );
1641    }
1642
1643    #[test]
1644    fn detokenize_ansi_c_octal_escape() {
1645        // $'\162\155' → rm (octal: 162=0x72='r', 155=0x6d='m')
1646        let detok = detokenize_command("$'\\162\\155'");
1647        assert!(
1648            detok.contains("rm"),
1649            "Octal-encoded 'rm' should decode, got: {:?}",
1650            detok
1651        );
1652    }
1653
1654    #[test]
1655    fn detokenize_ansi_c_newline_expansion() {
1656        // $'rm\n-rf\n/' → newline becomes space for token visibility
1657        let detok = detokenize_command("$'rm\\n-rf\\n/'");
1658        assert!(detok.contains("rm"));
1659        assert!(detok.contains("-rf"));
1660    }
1661
1662    #[test]
1663    fn detokenize_ansi_c_combined_escapes() {
1664        // $'m\\x6bfs' → mkfs (literal m + hex k + literal fs)
1665        let detok = detokenize_command("$'m\\x6bfs'");
1666        assert!(
1667            detok.contains("mkfs"),
1668            "Should decode to mkfs, got: {:?}",
1669            detok
1670        );
1671    }
1672
1673    #[test]
1674    fn blocks_rm_via_ansi_c_bypass() {
1675        // GAP-01: $'rm\t-rf\t/' should be blocked
1676        let err = Capability::execute(
1677            &ShellExec,
1678            &serde_json::json!({"cmd": "$'rm\\t-rf\\t/'"}),
1679            &Context {
1680                dry_run: false,
1681                job_id: "test".into(),
1682                working_dir: std::env::temp_dir(),
1683            },
1684        )
1685        .unwrap_err();
1686        let msg = format!("{}", err);
1687        assert!(
1688            msg.contains("recursive rm") || msg.contains("dangerous command blocked"),
1689            "Should block ANSI-C quoted rm, got: {}",
1690            msg
1691        );
1692    }
1693
1694    #[test]
1695    fn blocks_rm_via_ansi_c_hex_bypass() {
1696        // GAP-06: $'\x72\x6d' -rf / should be blocked
1697        let err = Capability::execute(
1698            &ShellExec,
1699            &serde_json::json!({"cmd": "$'\\x72\\x6d' -rf /"}),
1700            &Context {
1701                dry_run: false,
1702                job_id: "test".into(),
1703                working_dir: std::env::temp_dir(),
1704            },
1705        )
1706        .unwrap_err();
1707        assert!(
1708            format!("{}", err).contains("recursive rm")
1709                || format!("{}", err).contains("rm command blocked"),
1710            "Should block hex-encoded rm bypass"
1711        );
1712    }
1713
1714    // ── GAP-14: chmod quoting bypass test ──────────────────────────────
1715
1716    #[test]
1717    fn blocks_chmod_via_quote_bypass() {
1718        // c"hmod" 777 / should be blocked (detokenized to chmod 777 /)
1719        let err = Capability::execute(
1720            &ShellExec,
1721            &serde_json::json!({"cmd": "c\"hmod\" 777 /"}),
1722            &Context {
1723                dry_run: false,
1724                job_id: "test".into(),
1725                working_dir: std::env::temp_dir(),
1726            },
1727        )
1728        .unwrap_err();
1729        assert!(
1730            format!("{}", err).contains("chmod"),
1731            "Should block c\"hmod\" 777 / (quoted chmod bypass)"
1732        );
1733    }
1734
1735    // ── Original detokenizer tests ──────────────────────────────────────
1736
1737    // ── GAP-02: Multi-pass + backslash-newline tests ───────────────────
1738
1739    #[test]
1740    fn detokenize_backslash_newline_continuation() {
1741        // r\<newline>m -rf / → should join as rm -rf /
1742        let cmd_with_newline = "r\\\nm -rf /";
1743        let detok = detokenize_command(cmd_with_newline);
1744        assert!(
1745            detok.contains("rm"),
1746            "Backslash-newline should be stripped, got: {:?}",
1747            detok
1748        );
1749        assert!(
1750            detok.contains("rm -rf /"),
1751            "Should rejoin tokens across newline, got: {:?}",
1752            detok
1753        );
1754    }
1755
1756    #[test]
1757    fn blocks_rm_via_backslash_newline_bypass() {
1758        // GAP-02: r\<newline>m -rf / should be blocked
1759        let err = Capability::execute(
1760            &ShellExec,
1761            &serde_json::json!({"cmd": "r\\\nm -rf /"}),
1762            &Context {
1763                dry_run: false,
1764                job_id: "test".into(),
1765                working_dir: std::env::temp_dir(),
1766            },
1767        )
1768        .unwrap_err();
1769        assert!(
1770            format!("{}", err).contains("recursive rm")
1771                || format!("{}", err).contains("rm command blocked"),
1772            "Should block backslash-newline rm bypass"
1773        );
1774    }
1775
1776    #[test]
1777    fn detokenize_multi_pass_stability() {
1778        // Triple-nested quotes should be fully stripped
1779        let detok = detokenize_command("\"'rm'\" -rf /");
1780        // After multi-pass: 'rm' → rm (single quotes stripped second pass)
1781        assert!(
1782            detok.contains("rm"),
1783            "Multi-pass should converge, got: {:?}",
1784            detok
1785        );
1786    }
1787
1788    #[test]
1789    fn detokenize_roundtrip_idempotent() {
1790        // After detokenization, running again should produce the same result
1791        let cmd = "r\"m\" -rf /";
1792        let detok1 = detokenize_command(cmd);
1793        let detok2 = detokenize_command(&detok1);
1794        assert_eq!(detok1, detok2, "Detokenization should be idempotent");
1795    }
1796
1797    // ── GAP-03: Heredoc tests ──────────────────────────────────────────
1798
1799    #[test]
1800    fn blocks_heredoc() {
1801        // <<EOF followed by any content should be blocked
1802        let err = Capability::execute(
1803            &ShellExec,
1804            &serde_json::json!({"cmd": "cat <<EOF\nevil\nEOF"}),
1805            &Context {
1806                dry_run: false,
1807                job_id: "test".into(),
1808                working_dir: std::env::temp_dir(),
1809            },
1810        )
1811        .unwrap_err();
1812        let msg = format!("{}", err);
1813        assert!(
1814            msg.contains("heredoc"),
1815            "Should block heredoc (<<), got: {}",
1816            msg
1817        );
1818    }
1819
1820    #[test]
1821    fn blocks_herestring() {
1822        // <<< is a herestring, also blocked
1823        let err = Capability::execute(
1824            &ShellExec,
1825            &serde_json::json!({"cmd": "cat <<<\"hello\""}),
1826            &Context {
1827                dry_run: false,
1828                job_id: "test".into(),
1829                working_dir: std::env::temp_dir(),
1830            },
1831        )
1832        .unwrap_err();
1833        assert!(
1834            format!("{}", err).contains("heredoc"),
1835            "Should block herestring (<<<)"
1836        );
1837    }
1838
1839    #[test]
1840    fn blocks_heredoc_via_quote_bypass() {
1841        // << via quoting bypass: <"<"
1842        let err = Capability::execute(
1843            &ShellExec,
1844            &serde_json::json!({"cmd": "<\"<\"EOF\nevil\nEOF"}),
1845            &Context {
1846                dry_run: false,
1847                job_id: "test".into(),
1848                working_dir: std::env::temp_dir(),
1849            },
1850        )
1851        .unwrap_err();
1852        assert!(
1853            format!("{}", err).contains("heredoc"),
1854            "Should block quoted heredoc bypass"
1855        );
1856    }
1857
1858    // ── GAP-04: Process substitution tests ─────────────────────────────
1859
1860    #[test]
1861    fn blocks_process_substitution_input() {
1862        // <(curl ...) process substitution should be blocked
1863        let err = Capability::execute(
1864            &ShellExec,
1865            &serde_json::json!({"cmd": "diff <(curl http://evil) <(ls)"}),
1866            &Context {
1867                dry_run: false,
1868                job_id: "test".into(),
1869                working_dir: std::env::temp_dir(),
1870            },
1871        )
1872        .unwrap_err();
1873        assert!(
1874            format!("{}", err).contains("process substitution"),
1875            "Should block <( ) process substitution"
1876        );
1877    }
1878
1879    #[test]
1880    fn blocks_process_substitution_output() {
1881        // >(tee /etc/cron.d/evil) process substitution should be blocked
1882        let err = Capability::execute(
1883            &ShellExec,
1884            &serde_json::json!({"cmd": "echo evil > >(tee /etc/cron.d/backdoor)"}),
1885            &Context {
1886                dry_run: false,
1887                job_id: "test".into(),
1888                working_dir: std::env::temp_dir(),
1889            },
1890        )
1891        .unwrap_err();
1892        assert!(
1893            format!("{}", err).contains("process substitution"),
1894            "Should block >( ) process substitution"
1895        );
1896    }
1897
1898    // ── GAP-05: Command substitution tests ─────────────────────────────
1899
1900    #[test]
1901    fn blocks_command_substitution_dollar_paren() {
1902        // $(echo rm) should be blocked
1903        let err = Capability::execute(
1904            &ShellExec,
1905            &serde_json::json!({"cmd": "$(echo rm) -rf /"}),
1906            &Context {
1907                dry_run: false,
1908                job_id: "test".into(),
1909                working_dir: std::env::temp_dir(),
1910            },
1911        )
1912        .unwrap_err();
1913        assert!(
1914            format!("{}", err).contains("command substitution"),
1915            "Should block $( ) command substitution"
1916        );
1917    }
1918
1919    #[test]
1920    fn blocks_command_substitution_backtick() {
1921        // `echo rm` backtick substitution should be blocked
1922        let err = Capability::execute(
1923            &ShellExec,
1924            &serde_json::json!({"cmd": "`echo rm` -rf /"}),
1925            &Context {
1926                dry_run: false,
1927                job_id: "test".into(),
1928                working_dir: std::env::temp_dir(),
1929            },
1930        )
1931        .unwrap_err();
1932        assert!(
1933            format!("{}", err).contains("command substitution"),
1934            "Should block backtick command substitution"
1935        );
1936    }
1937
1938    #[test]
1939    fn blocks_command_substitution_in_double_quotes() {
1940        // "$(echo rm) -rf /" — substitution inside double quotes
1941        let err = Capability::execute(
1942            &ShellExec,
1943            &serde_json::json!({"cmd": "\"$(echo rm)\" -rf /"}),
1944            &Context {
1945                dry_run: false,
1946                job_id: "test".into(),
1947                working_dir: std::env::temp_dir(),
1948            },
1949        )
1950        .unwrap_err();
1951        assert!(
1952            format!("{}", err).contains("command substitution"),
1953            "Should block $( ) even inside double quotes"
1954        );
1955    }
1956
1957    // ── Original detokenizer tests ──────────────────────────────────────
1958
1959    #[test]
1960    fn detokenize_strips_double_quotes() {
1961        // r"m" -rf / → rm -rf /
1962        assert_eq!(detokenize_command("r\"m\" -rf /"), "rm -rf /");
1963    }
1964
1965    #[test]
1966    fn detokenize_strips_single_quotes() {
1967        // 'r''m' -rf / → rm -rf /
1968        assert_eq!(detokenize_command("'r''m' -rf /"), "rm -rf /");
1969    }
1970
1971    #[test]
1972    fn detokenize_strips_backslash() {
1973        // r\m -rf / → rm -rf /
1974        assert_eq!(detokenize_command("r\\m -rf /"), "rm -rf /");
1975    }
1976
1977    #[test]
1978    fn detokenize_mixed_quotes() {
1979        // r"m" -r"f" → rm -rf
1980        assert_eq!(detokenize_command("r\"m\" -r\"f\""), "rm -rf");
1981    }
1982
1983    #[test]
1984    fn detokenize_preserves_non_quoted() {
1985        // Normal commands pass through unchanged
1986        assert_eq!(detokenize_command("echo hello"), "echo hello");
1987        assert_eq!(detokenize_command("ls -la /tmp"), "ls -la /tmp");
1988    }
1989
1990    #[test]
1991    fn blocks_rm_via_double_quote_bypass() {
1992        // F-013: r"m" -rf / should be blocked (was NOT previously)
1993        let err = Capability::execute(
1994            &ShellExec,
1995            &serde_json::json!({"cmd": "r\"m\" -rf /"}),
1996            &Context {
1997                dry_run: false,
1998                job_id: "test".into(),
1999                working_dir: std::env::temp_dir(),
2000            },
2001        )
2002        .unwrap_err();
2003        let msg = format!("{}", err);
2004        assert!(
2005            msg.contains("dangerous command blocked") || msg.contains("recursive rm"),
2006            "Should block r\"m\" -rf /, got: {}",
2007            msg
2008        );
2009    }
2010
2011    #[test]
2012    fn blocks_rm_via_single_quote_bypass() {
2013        // F-013: 'r''m' -rf / should be blocked
2014        let err = Capability::execute(
2015            &ShellExec,
2016            &serde_json::json!({"cmd": "'r''m' -rf /"}),
2017            &Context {
2018                dry_run: false,
2019                job_id: "test".into(),
2020                working_dir: std::env::temp_dir(),
2021            },
2022        )
2023        .unwrap_err();
2024        assert!(
2025            format!("{}", err).contains("recursive rm")
2026                || format!("{}", err).contains("rm command blocked"),
2027            "Should block 'r''m' -rf /"
2028        );
2029    }
2030
2031    #[test]
2032    fn blocks_rm_via_backslash_bypass() {
2033        // F-013: r\m -rf / should be blocked
2034        let err = Capability::execute(
2035            &ShellExec,
2036            &serde_json::json!({"cmd": "r\\m -rf /"}),
2037            &Context {
2038                dry_run: false,
2039                job_id: "test".into(),
2040                working_dir: std::env::temp_dir(),
2041            },
2042        )
2043        .unwrap_err();
2044        assert!(
2045            format!("{}", err).contains("recursive rm")
2046                || format!("{}", err).contains("rm command blocked"),
2047            "Should block r\\m -rf /"
2048        );
2049    }
2050
2051    #[test]
2052    fn blocks_dd_via_quote_bypass() {
2053        // F-013: d"d" if=/dev/zero should be blocked
2054        let err = Capability::execute(
2055            &ShellExec,
2056            &serde_json::json!({"cmd": "d\"d\" if=/dev/zero"}),
2057            &Context {
2058                dry_run: false,
2059                job_id: "test".into(),
2060                working_dir: std::env::temp_dir(),
2061            },
2062        )
2063        .unwrap_err();
2064        assert!(
2065            format!("{}", err).contains("dd"),
2066            "Should block d\"d\" (dd bypass)"
2067        );
2068    }
2069
2070    // ── F-014: Path Restriction Tests ────────────────────────────────────
2071
2072    #[test]
2073    fn blocks_cat_outside_allowed() {
2074        // cat /etc/passwd is outside allowed prefixes (/tmp, /var/tmp, /home)
2075        let err = Capability::execute(
2076            &ShellExec,
2077            &serde_json::json!({"cmd": "cat /etc/passwd"}),
2078            &Context {
2079                dry_run: false,
2080                job_id: "test".into(),
2081                working_dir: std::env::temp_dir(),
2082            },
2083        )
2084        .unwrap_err();
2085        assert!(
2086            format!("{}", err).contains("outside allowed directories"),
2087            "Should block cat /etc/passwd"
2088        );
2089    }
2090
2091    #[test]
2092    fn blocks_ls_tilde_ssh() {
2093        // ls ~/.ssh/ expands to /home/user/.ssh/ which is within /home but
2094        // .ssh is fine as it's a subdirectory — wait, ~/.ssh/ IS within /home.
2095        // Let me test with a path outside /home like ~/../root/.ssh
2096        // Actually ~/../root is traversal, which path validation catches.
2097        // Test: ls ~/../../etc/passwd contains ".." which is rejected.
2098        let err = Capability::execute(
2099            &ShellExec,
2100            &serde_json::json!({"cmd": "ls ~/../../etc/passwd"}),
2101            &Context {
2102                dry_run: false,
2103                job_id: "test".into(),
2104                working_dir: std::env::temp_dir(),
2105            },
2106        )
2107        .unwrap_err();
2108        // Should be blocked by ShellExec path check or by the ".." rejection
2109        let msg = format!("{}", err);
2110        assert!(
2111            msg.contains("outside allowed") || msg.contains("traversal"),
2112            "Should block ls ~/../../etc/passwd, got: {}",
2113            msg
2114        );
2115    }
2116
2117    #[test]
2118    fn blocks_path_to_root() {
2119        // Reading files in /root/ should be blocked (outside allowed prefixes)
2120        let err = Capability::execute(
2121            &ShellExec,
2122            &serde_json::json!({"cmd": "cat /root/.bashrc"}),
2123            &Context {
2124                dry_run: false,
2125                job_id: "test".into(),
2126                working_dir: std::env::temp_dir(),
2127            },
2128        )
2129        .unwrap_err();
2130        assert!(
2131            format!("{}", err).contains("outside allowed directories"),
2132            "Should block cat /root/.bashrc"
2133        );
2134    }
2135
2136    #[test]
2137    fn allows_cat_in_tmp() {
2138        // cat /tmp/somefile should be ALLOWED (within /tmp prefix)
2139        // Use echo to avoid the file-not-found issue
2140        let r = Capability::execute(
2141            &ShellExec,
2142            &serde_json::json!({"cmd": "echo test > /tmp/runtimo_path_test.txt && cat /tmp/runtimo_path_test.txt"}),
2143            &Context {
2144                dry_run: false,
2145                job_id: "test".into(),
2146                working_dir: std::env::temp_dir(),
2147            },
2148        );
2149        // Clean up
2150        let _ = std::fs::remove_file("/tmp/runtimo_path_test.txt");
2151        match r {
2152            Ok(o) => assert_eq!(o.status, "ok", "Should allow cat in /tmp"),
2153            Err(e) => {
2154                let msg = format!("{}", e);
2155                assert!(
2156                    !msg.contains("outside allowed"),
2157                    "Should NOT block /tmp path, got: {}",
2158                    msg
2159                );
2160            }
2161        }
2162    }
2163
2164    #[test]
2165    fn allows_cat_in_home() {
2166        // cat $HOME/somefile should be ALLOWED (within /home prefix)
2167        let home = std::env::var("HOME").unwrap_or_else(|_| "/home/user".to_string());
2168        let test_path = format!("{}/runtimo_home_path_test.txt", home);
2169        let cmd = format!("echo ok > {} && cat {}", test_path, test_path);
2170        let r = Capability::execute(
2171            &ShellExec,
2172            &serde_json::json!({"cmd": cmd}),
2173            &Context {
2174                dry_run: false,
2175                job_id: "test".into(),
2176                working_dir: std::env::temp_dir(),
2177            },
2178        );
2179        let _ = std::fs::remove_file(&test_path);
2180        match r {
2181            Ok(o) => assert_eq!(o.status, "ok", "Should allow cat in HOME"),
2182            Err(e) => {
2183                let msg = format!("{}", e);
2184                assert!(
2185                    !msg.contains("outside allowed"),
2186                    "Should NOT block HOME path, got: {}",
2187                    msg
2188                );
2189            }
2190        }
2191    }
2192
2193    // ── GAP-08: Variable-expanded path tests ───────────────────────────
2194
2195    #[test]
2196    fn blocks_var_expanded_path_outside_allowed() {
2197        // cat $HOME/.ssh/id_ed25519 should be blocked (outside /tmp, /var/tmp,
2198        // but typically inside /home. Actually this IS within /home prefix.
2199        // Let's use a path that's definitely outside: /etc/shadow via variable)
2200        // Test: cat $HOME/../../etc/shadow — contains ".." which is blocked
2201        let err = Capability::execute(
2202            &ShellExec,
2203            &serde_json::json!({"cmd": "cat $HOME/../../etc/shadow"}),
2204            &Context {
2205                dry_run: false,
2206                job_id: "test".into(),
2207                working_dir: std::env::temp_dir(),
2208            },
2209        )
2210        .unwrap_err();
2211        let msg = format!("{}", err);
2212        assert!(
2213            msg.contains("outside allowed") || msg.contains("traversal"),
2214            "Should block $HOME/../../etc/shadow, got: {}",
2215            msg
2216        );
2217    }
2218
2219    #[test]
2220    fn blocks_var_brace_expanded_path() {
2221        // cat ${HOME}/../../etc/shadow — brace syntax
2222        let err = Capability::execute(
2223            &ShellExec,
2224            &serde_json::json!({"cmd": "cat ${HOME}/../../etc/shadow"}),
2225            &Context {
2226                dry_run: false,
2227                job_id: "test".into(),
2228                working_dir: std::env::temp_dir(),
2229            },
2230        )
2231        .unwrap_err();
2232        let msg = format!("{}", err);
2233        assert!(
2234            msg.contains("outside allowed") || msg.contains("traversal"),
2235            "Should block brace-syntax var expansion"
2236        );
2237    }
2238
2239    #[test]
2240    fn test_expand_shell_vars_resolves_home() {
2241        // expand_shell_vars should resolve $HOME to the actual home directory
2242        let expanded = expand_shell_vars("$HOME/.ssh");
2243        let home = std::env::var("HOME").unwrap_or_default();
2244        assert!(
2245            expanded.starts_with(&home),
2246            "Should expand $HOME, got: {}",
2247            expanded
2248        );
2249        assert!(
2250            expanded.ends_with("/.ssh"),
2251            "Should keep suffix, got: {}",
2252            expanded
2253        );
2254    }
2255
2256    #[test]
2257    fn test_expand_shell_vars_brace_syntax() {
2258        let expanded = expand_shell_vars("${HOME}/.ssh");
2259        let home = std::env::var("HOME").unwrap_or_default();
2260        assert!(
2261            expanded.starts_with(&home),
2262            "Should expand brace-syntax var"
2263        );
2264    }
2265
2266    // ── GAP-10: Inline redirect path tests ────────────────────────────
2267
2268    #[test]
2269    fn blocks_redirect_to_outside_path() {
2270        // echo evil >/etc/cron.d/backdoor — redirect path should be checked
2271        let err = Capability::execute(
2272            &ShellExec,
2273            &serde_json::json!({"cmd": "echo evil >/etc/cron.d/backdoor"}),
2274            &Context {
2275                dry_run: false,
2276                job_id: "test".into(),
2277                working_dir: std::env::temp_dir(),
2278            },
2279        )
2280        .unwrap_err();
2281        assert!(
2282            format!("{}", err).contains("outside allowed directories"),
2283            "Should block redirect to /etc/cron.d/backdoor"
2284        );
2285    }
2286
2287    #[test]
2288    fn blocks_append_redirect_to_outside_path() {
2289        // echo evil >>/etc/hosts — append redirect should also be checked
2290        let err = Capability::execute(
2291            &ShellExec,
2292            &serde_json::json!({"cmd": "echo evil >>/etc/hosts"}),
2293            &Context {
2294                dry_run: false,
2295                job_id: "test".into(),
2296                working_dir: std::env::temp_dir(),
2297            },
2298        )
2299        .unwrap_err();
2300        assert!(
2301            format!("{}", err).contains("outside allowed directories"),
2302            "Should block append redirect to /etc/hosts"
2303        );
2304    }
2305
2306    #[test]
2307    fn blocks_stderr_redirect_outside() {
2308        // cmd 2>/etc/malicious — stderr redirect should be checked
2309        let err = Capability::execute(
2310            &ShellExec,
2311            &serde_json::json!({"cmd": "ls 2>/etc/malicious"}),
2312            &Context {
2313                dry_run: false,
2314                job_id: "test".into(),
2315                working_dir: std::env::temp_dir(),
2316            },
2317        )
2318        .unwrap_err();
2319        assert!(
2320            format!("{}", err).contains("outside allowed directories"),
2321            "Should block 2> redirect to /etc/malicious"
2322        );
2323    }
2324
2325    #[test]
2326    fn allows_redirect_to_allowed_path() {
2327        // echo hello >/tmp/test_redirect.txt should be allowed
2328        let r = Capability::execute(
2329            &ShellExec,
2330            &serde_json::json!({"cmd": "echo hello >/tmp/runtimo_redirect_test.txt"}),
2331            &Context {
2332                dry_run: false,
2333                job_id: "test".into(),
2334                working_dir: std::env::temp_dir(),
2335            },
2336        );
2337        let _ = std::fs::remove_file("/tmp/runtimo_redirect_test.txt");
2338        match r {
2339            Ok(o) => assert_eq!(o.status, "ok"),
2340            Err(e) => {
2341                assert!(
2342                    !format!("{}", e).contains("outside allowed"),
2343                    "Should NOT block redirect to /tmp, got: {}",
2344                    e
2345                );
2346            }
2347        }
2348    }
2349
2350    // ── GAP-13: Relative path traversal tests ──────────────────────────
2351
2352    #[test]
2353    fn blocks_relative_parent_traversal() {
2354        // cat ../../etc/passwd — relative path escaping from /tmp
2355        let err = Capability::execute(
2356            &ShellExec,
2357            &serde_json::json!({"cmd": "cat ../../etc/passwd"}),
2358            &Context {
2359                dry_run: false,
2360                job_id: "test".into(),
2361                working_dir: std::env::temp_dir(),
2362            },
2363        )
2364        .unwrap_err();
2365        assert!(
2366            format!("{}", err).contains("outside allowed directories"),
2367            "Should block relative path traversal to /etc/passwd"
2368        );
2369    }
2370
2371    #[test]
2372    fn blocks_deep_relative_traversal() {
2373        // cat ./../../../etc/shadow — deeper traversal
2374        let err = Capability::execute(
2375            &ShellExec,
2376            &serde_json::json!({"cmd": "cat ./../../../etc/shadow"}),
2377            &Context {
2378                dry_run: false,
2379                job_id: "test".into(),
2380                working_dir: std::env::temp_dir(),
2381            },
2382        )
2383        .unwrap_err();
2384        assert!(
2385            format!("{}", err).contains("outside allowed directories"),
2386            "Should block deep relative traversal"
2387        );
2388    }
2389
2390    #[test]
2391    fn allows_relative_within_allowed() {
2392        // Relative paths resolve against CWD. During testing, CWD is
2393        // the project root (outside /tmp), so relative paths won't be
2394        // within allowed prefixes. Use an absolute /tmp path instead
2395        // for the "allowed" case.
2396        let test_file = "/tmp/runtimo_relative_allowed_test.txt";
2397        let r = Capability::execute(
2398            &ShellExec,
2399            &serde_json::json!({"cmd": format!("echo ok > {}", test_file)}),
2400            &Context {
2401                dry_run: false,
2402                job_id: "test".into(),
2403                working_dir: std::env::temp_dir(),
2404            },
2405        );
2406        let _ = std::fs::remove_file(test_file);
2407        match r {
2408            Ok(o) => assert_eq!(o.status, "ok", "Should allow path within /tmp"),
2409            Err(e) => {
2410                let msg = format!("{}", e);
2411                assert!(
2412                    !msg.contains("outside allowed"),
2413                    "Should NOT block /tmp path, got: {}",
2414                    msg
2415                );
2416            }
2417        }
2418    }
2419
2420    // ── F-015: Env Protection Tests ─────────────────────────────────────
2421
2422    #[test]
2423    fn blocks_env_command() {
2424        let err = Capability::execute(
2425            &ShellExec,
2426            &serde_json::json!({"cmd": "env"}),
2427            &Context {
2428                dry_run: false,
2429                job_id: "test".into(),
2430                working_dir: std::env::temp_dir(),
2431            },
2432        )
2433        .unwrap_err();
2434        assert!(
2435            format!("{}", err).contains("environment variable dumping"),
2436            "Should block `env` command"
2437        );
2438    }
2439
2440    #[test]
2441    fn blocks_printenv_command() {
2442        let err = Capability::execute(
2443            &ShellExec,
2444            &serde_json::json!({"cmd": "printenv"}),
2445            &Context {
2446                dry_run: false,
2447                job_id: "test".into(),
2448                working_dir: std::env::temp_dir(),
2449            },
2450        )
2451        .unwrap_err();
2452        assert!(
2453            format!("{}", err).contains("environment variable dumping"),
2454            "Should block `printenv` command"
2455        );
2456    }
2457
2458    #[test]
2459    fn blocks_set_command() {
2460        let err = Capability::execute(
2461            &ShellExec,
2462            &serde_json::json!({"cmd": "set"}),
2463            &Context {
2464                dry_run: false,
2465                job_id: "test".into(),
2466                working_dir: std::env::temp_dir(),
2467            },
2468        )
2469        .unwrap_err();
2470        assert!(
2471            format!("{}", err).contains("environment variable dumping"),
2472            "Should block `set` command"
2473        );
2474    }
2475
2476    #[test]
2477    fn blocks_export_command() {
2478        let err = Capability::execute(
2479            &ShellExec,
2480            &serde_json::json!({"cmd": "export"}),
2481            &Context {
2482                dry_run: false,
2483                job_id: "test".into(),
2484                working_dir: std::env::temp_dir(),
2485            },
2486        )
2487        .unwrap_err();
2488        assert!(
2489            format!("{}", err).contains("environment variable dumping"),
2490            "Should block `export` command"
2491        );
2492    }
2493
2494    // ── GAP-12: export with assignment still blocked ──────────────────
2495
2496    #[test]
2497    fn blocks_export_with_assignment() {
2498        // GAP-12: export FOO=bar should also be blocked — the export
2499        // keyword itself triggers the env-dumping blocklist.
2500        let err = Capability::execute(
2501            &ShellExec,
2502            &serde_json::json!({"cmd": "export FOO=bar"}),
2503            &Context {
2504                dry_run: false,
2505                job_id: "test".into(),
2506                working_dir: std::env::temp_dir(),
2507            },
2508        )
2509        .unwrap_err();
2510        assert!(
2511            format!("{}", err).contains("environment variable dumping"),
2512            "Should block `export FOO=bar` (export with assignment)"
2513        );
2514    }
2515
2516    #[test]
2517    fn blocks_declare_p_command() {
2518        let err = Capability::execute(
2519            &ShellExec,
2520            &serde_json::json!({"cmd": "declare -p"}),
2521            &Context {
2522                dry_run: false,
2523                job_id: "test".into(),
2524                working_dir: std::env::temp_dir(),
2525            },
2526        )
2527        .unwrap_err();
2528        assert!(
2529            format!("{}", err).contains("environment variable dumping"),
2530            "Should block `declare -p` command"
2531        );
2532    }
2533
2534    #[test]
2535    fn blocks_env_via_quote_bypass() {
2536        // F-013 + F-015: e"n"v should also be blocked
2537        let err = Capability::execute(
2538            &ShellExec,
2539            &serde_json::json!({"cmd": "e\"n\"v"}),
2540            &Context {
2541                dry_run: false,
2542                job_id: "test".into(),
2543                working_dir: std::env::temp_dir(),
2544            },
2545        )
2546        .unwrap_err();
2547        assert!(
2548            format!("{}", err).contains("environment variable dumping"),
2549            "Should block e\"n\"v (quoted env bypass)"
2550        );
2551    }
2552
2553    #[test]
2554    fn allows_harmless_command_with_env_check() {
2555        // Normal commands should still work even with env sanitization
2556        let r = Capability::execute(
2557            &ShellExec,
2558            &serde_json::json!({"cmd": "echo hello"}),
2559            &Context {
2560                dry_run: false,
2561                job_id: "test".into(),
2562                working_dir: std::env::temp_dir(),
2563            },
2564        )
2565        .unwrap();
2566        assert_eq!(r.status, "ok");
2567        assert!(r.data.as_ref().unwrap()["stdout"]
2568            .as_str()
2569            .unwrap()
2570            .contains("hello"));
2571    }
2572
2573    #[test]
2574    fn is_sensitive_env_var_detects_aws() {
2575        assert!(is_sensitive_env_var("AWS_ACCESS_KEY_ID"));
2576        assert!(is_sensitive_env_var("AWS_SECRET_ACCESS_KEY"));
2577        assert!(is_sensitive_env_var("aws_session_token")); // case-insensitive
2578    }
2579
2580    #[test]
2581    fn is_sensitive_env_var_detects_suffixes() {
2582        assert!(is_sensitive_env_var("MYAPP_API_KEY"));
2583        assert!(is_sensitive_env_var("GITHUB_TOKEN"));
2584        assert!(is_sensitive_env_var("DB_PASSWORD"));
2585        assert!(is_sensitive_env_var("STRIPE_SECRET_KEY"));
2586    }
2587
2588    #[test]
2589    fn is_sensitive_env_var_allows_safe() {
2590        assert!(!is_sensitive_env_var("HOME"));
2591        assert!(!is_sensitive_env_var("USER"));
2592        assert!(!is_sensitive_env_var("PATH"));
2593        assert!(!is_sensitive_env_var("TERM"));
2594        assert!(!is_sensitive_env_var("LANG"));
2595        assert!(!is_sensitive_env_var("RUNTIMO_ENABLE_NETWORK"));
2596    }
2597
2598    // ── GAP-07: Non-secret vars matching suffix patterns ──────────────
2599
2600    #[test]
2601    fn is_sensitive_env_var_allows_known_non_secret_suffix() {
2602        // GAP-07: FOREIGN_KEY matches *_KEY suffix but is a database term
2603        assert!(!is_sensitive_env_var("FOREIGN_KEY"));
2604        assert!(!is_sensitive_env_var("PRIMARY_KEY"));
2605        assert!(!is_sensitive_env_var("PUBLIC_KEY"));
2606        assert!(!is_sensitive_env_var("BASE_URL"));
2607    }
2608
2609    // ── GAP-15: Dynamic linker env var tests ───────────────────────────
2610
2611    #[test]
2612    fn is_sensitive_env_var_detects_ld_preload() {
2613        // LD_PRELOAD can inject arbitrary shared libraries
2614        assert!(is_sensitive_env_var("LD_PRELOAD"));
2615        assert!(is_sensitive_env_var("LD_LIBRARY_PATH"));
2616        assert!(is_sensitive_env_var("LD_DEBUG"));
2617        assert!(is_sensitive_env_var("LD_BIND_NOW"));
2618    }
2619
2620    #[test]
2621    fn is_sensitive_env_var_detects_dyld() {
2622        // macOS dynamic linker injection
2623        assert!(is_sensitive_env_var("DYLD_INSERT_LIBRARIES"));
2624        assert!(is_sensitive_env_var("DYLD_LIBRARY_PATH"));
2625    }
2626
2627    #[test]
2628    fn sanitized_env_strips_secrets() {
2629        // Set a test secret and verify it's stripped
2630        std::env::set_var("RUNTIMO_TEST_SECRET_KEY", "test-value");
2631        let env = sanitized_env();
2632        std::env::remove_var("RUNTIMO_TEST_SECRET_KEY");
2633
2634        assert!(
2635            !env.iter()
2636                .map(|(k, _)| k.as_str())
2637                .any(|x| x == "RUNTIMO_TEST_SECRET_KEY"),
2638            "RUNTIMO_TEST_SECRET_KEY should be stripped from env"
2639        );
2640    }
2641
2642    #[test]
2643    fn sanitized_env_preserves_safe() {
2644        let env = sanitized_env();
2645        let keys: Vec<&str> = env.iter().map(|(k, _)| k.as_str()).collect();
2646        assert!(keys.contains(&"HOME"), "HOME should be preserved");
2647        assert!(keys.contains(&"USER"), "USER should be preserved");
2648    }
2649}