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    // ── Config-based blocklist overrides ──
474    // Additional patterns from `~/.config/runtimo/config.toml` [blocklist_overrides].
475    // These are merged on top of the built-in blocklist for site-specific policy.
476    let overrides = crate::config::RuntimoConfig::get_blocklist_overrides();
477    for pattern in &overrides {
478        if !pattern.is_empty()
479            && (cmd_lower.contains(pattern.as_str()) || detok_lower.contains(pattern.as_str()))
480        {
481            return Some("command blocked by config override");
482        }
483    }
484
485    None
486}
487
488/// Tests whether a command invokes a network client.
489///
490/// Blocked tools: `curl`, `wget`, `nc`/`ncat`/`netcat`, `socat`,
491/// `ssh`, `scp`, `telnet`.
492///
493/// These are only blocked when `RUNTIMO_ENABLE_NETWORK` is not set to `"1"`.
494#[must_use]
495pub fn is_network_command(cmd: &str) -> bool {
496    let cmd_lower = cmd.to_lowercase();
497    command_matches(
498        &cmd_lower,
499        &[
500            "curl", "wget", "nc", "ncat", "netcat", "socat", "ssh", "scp", "telnet",
501        ],
502    )
503}
504
505/// Checks whether outbound network commands are permitted.
506///
507/// Returns `true` when network tools are allowed (env var set to `"1"`).
508#[must_use]
509pub fn network_enabled() -> bool {
510    std::env::var("RUNTIMO_ENABLE_NETWORK").as_deref() == Ok("1")
511}
512
513/// Tests whether a command invokes a scripting language interpreter.
514///
515/// Blocked tools: `python`, `python3`, `perl`, `ruby`, `node`, `lua`,
516/// `php`, `tclsh`, `wish`, `racket`, `guile`, `ghci`, `runghc`, `scala`,
517/// `gawk`, `nawk`.
518///
519/// Interpreters can execute arbitrary code including filesystem manipulation,
520/// network access, and process spawning — bypassing all blocklist protections.
521/// They are gated behind `RUNTIMO_ENABLE_INTERPRETERS=1`.
522#[must_use]
523pub fn is_interpreter_command(cmd: &str) -> bool {
524    let cmd_lower = cmd.to_lowercase();
525    command_matches(
526        &cmd_lower,
527        &[
528            "python", "python3", "python2", "perl", "ruby", "node", "lua", "php", "tclsh", "wish",
529            "racket", "guile", "ghci", "runghc", "scala", "gawk", "nawk",
530        ],
531    )
532}
533
534/// Checks whether interpreter commands are permitted.
535///
536/// Returns `true` when interpreters are allowed (env var set to `"1"`).
537/// Default is blocked — agents should use ShellExec for shell commands,
538/// not arbitrary interpreter invocations.
539#[must_use]
540pub fn interpreters_enabled() -> bool {
541    std::env::var("RUNTIMO_ENABLE_INTERPRETERS").as_deref() == Ok("1")
542}
543
544// ── F-013: Shell detokenizer ──────────────────────────────────────────────
545
546/// # Multi-pass detokenization (GAP-02)
547///
548/// This function runs detokenization repeatedly until the output stabilizes.
549/// This handles nested/complex quoting patterns that survive a single pass:
550/// - `r""m""` → pass 1: `rm` → stable (already handled by single pass)
551/// - Mixed ANSI-C + double-quote nesting: handled by repeat
552/// - Backslash-newline line continuations: removed in first pass
553///
554/// Each pass strips one layer of quoting; the loop terminates when no
555/// further changes are detected or after 16 passes (safety limit).
556#[must_use]
557pub fn detokenize_command(cmd: &str) -> String {
558    const MAX_PASSES: usize = 16;
559
560    let mut current = cmd.to_string();
561    let mut previous;
562    let mut passes: usize = 0;
563
564    loop {
565        previous = current.clone();
566        current = detokenize_single_pass(&previous);
567        passes = passes.saturating_add(1);
568        if current == previous || passes >= MAX_PASSES {
569            break;
570        }
571    }
572    current
573}
574
575/// Single-pass detokenizer — strips one layer of shell quoting.
576///
577/// See [`detokenize_command`] for the multi-pass wrapper.
578#[must_use]
579fn detokenize_single_pass(cmd: &str) -> String {
580    let mut result = String::with_capacity(cmd.len());
581    let mut chars = cmd.chars().peekable();
582
583    while let Some(&c) = chars.peek() {
584        match c {
585            '\\' => {
586                // Backslash escape: skip the backslash
587                chars.next(); // consume backslash
588                if let Some(next) = chars.next() {
589                    // GAP-02: backslash-newline is a line continuation —
590                    // both the backslash and newline are removed in POSIX sh.
591                    // Emit nothing so tokens on either side rejoin.
592                    if next != '\n' {
593                        result.push(next);
594                    }
595                }
596                // If backslash is last char, it just disappears
597            }
598            '$' => {
599                // Check for ANSI-C quoting: $'...' (GAP-01, GAP-06)
600                // Peek at the next character without consuming $
601                chars.next(); // consume $
602                if chars.peek() == Some(&'\'') {
603                    chars.next(); // consume the opening '
604                                  // Process ANSI-C quoted string until closing '
605                    while let Some(ch) = chars.next() {
606                        if ch == '\'' {
607                            break; // closing quote
608                        }
609                        if ch == '\\' {
610                            // ANSI-C escape sequence — expand to literal
611                            match chars.next() {
612                                Some('\\') => result.push('\\'),
613                                Some('\'') => result.push('\''),
614                                Some('"') => result.push('"'),
615                                Some('?') => result.push('?'),
616                                Some('a') => result.push('a'),
617                                Some('b') => result.push('b'),
618                                Some('f') => result.push('f'),
619                                Some('n' | 'r' | 't' | 'v') => {
620                                    // whitespace escapes → space for token visibility
621                                    result.push(' ');
622                                }
623                                Some('e' | 'E') => result.push('e'),
624                                // \xHH — hex escape (up to 2 hex digits)
625                                Some('x') => {
626                                    let mut hex = String::new();
627                                    for _ in 0..2 {
628                                        if let Some(&h) = chars.peek() {
629                                            if h.is_ascii_hexdigit() {
630                                                hex.push(h);
631                                                chars.next();
632                                            } else {
633                                                break;
634                                            }
635                                        }
636                                    }
637                                    if let Ok(byte) = u8::from_str_radix(&hex, 16) {
638                                        if let Some(c) = char::from_u32(u32::from(byte)) {
639                                            result.push(c);
640                                        }
641                                    }
642                                }
643                                // \uHHHH — 16-bit unicode escape
644                                Some('u') => {
645                                    let mut hex = String::new();
646                                    for _ in 0..4 {
647                                        if let Some(&h) = chars.peek() {
648                                            if h.is_ascii_hexdigit() {
649                                                hex.push(h);
650                                                chars.next();
651                                            } else {
652                                                break;
653                                            }
654                                        }
655                                    }
656                                    if let Ok(cp) = u32::from_str_radix(&hex, 16) {
657                                        if let Some(c) = char::from_u32(cp) {
658                                            result.push(c);
659                                        }
660                                    }
661                                }
662                                // \UHHHHHHHH — 32-bit unicode escape
663                                Some('U') => {
664                                    let mut hex = String::new();
665                                    for _ in 0..8 {
666                                        if let Some(&h) = chars.peek() {
667                                            if h.is_ascii_hexdigit() {
668                                                hex.push(h);
669                                                chars.next();
670                                            } else {
671                                                break;
672                                            }
673                                        }
674                                    }
675                                    if let Ok(cp) = u32::from_str_radix(&hex, 16) {
676                                        if let Some(c) = char::from_u32(cp) {
677                                            result.push(c);
678                                        }
679                                    }
680                                }
681                                // \NNN — octal escape (1-3 octal digits)
682                                Some(d) if ('0'..='7').contains(&d) => {
683                                    let mut octal = String::from(d);
684                                    for _ in 0..2 {
685                                        if let Some(&od) = chars.peek() {
686                                            if ('0'..='7').contains(&od) {
687                                                octal.push(od);
688                                                chars.next();
689                                            } else {
690                                                break;
691                                            }
692                                        }
693                                    }
694                                    if let Ok(byte) = u8::from_str_radix(&octal, 8) {
695                                        if let Some(c) = char::from_u32(u32::from(byte)) {
696                                            result.push(c);
697                                        }
698                                    }
699                                }
700                                // \cX — control character (output X for visibility)
701                                Some(ctrl) => result.push(ctrl),
702                                // lone backslash at end of string: drop it
703                                None => {}
704                            }
705                        } else {
706                            result.push(ch);
707                        }
708                    }
709                } else {
710                    // Not ANSI-C quoting, emit $ as regular character
711                    result.push('$');
712                }
713            }
714            '\'' => {
715                // Single-quoted string: everything literal until closing '
716                chars.next(); // consume opening quote
717                for ch in chars.by_ref() {
718                    if ch == '\'' {
719                        break; // closing quote consumed, don't emit
720                    }
721                    result.push(ch);
722                }
723            }
724            '"' => {
725                // Double-quoted string: backslash escapes for "$`\ and newline
726                chars.next(); // consume opening quote
727                while let Some(ch) = chars.next() {
728                    if ch == '"' {
729                        break; // closing quote
730                    }
731                    if ch == '\\' {
732                        if let Some(&next_ch) = chars.peek() {
733                            match next_ch {
734                                '"' | '$' | '`' | '\\' | '\n' => {
735                                    chars.next(); // consume backslash
736                                    result.push(next_ch);
737                                    continue;
738                                }
739                                _ => {
740                                    // Not a special escape, keep the backslash
741                                    result.push('\\');
742                                    // next_ch will be handled by next iteration
743                                    continue;
744                                }
745                            }
746                        }
747                        // backslash at end of input, keep it
748                        result.push('\\');
749                        break;
750                    }
751                    result.push(ch);
752                }
753            }
754            _ => {
755                result.push(c);
756                chars.next(); // consume
757            }
758        }
759    }
760    result
761}
762
763// ── F-015: Environment sanitization ────────────────────────────────────────
764
765/// Returns `true` when the given environment variable name carries sensitive
766/// data and should be stripped before shell execution.
767///
768/// Checks both prefix-based patterns (e.g. `AWS_*`) and suffix-based patterns
769/// (e.g. `*_KEY`, `*_TOKEN`). The safe-list of known-safe variables overrides
770/// the blocklist.
771#[must_use]
772fn is_sensitive_env_var(key: &str) -> bool {
773    // Safe-list check first — explicit allowance overrides blocklist
774    if SAFE_ENV_VARS.contains(&key) {
775        return false;
776    }
777    let key_upper = key.to_uppercase();
778    // Prefix match
779    if SENSITIVE_ENV_PREFIXES
780        .iter()
781        .any(|prefix| key_upper.starts_with(prefix))
782    {
783        return true;
784    }
785    // Suffix match (e.g. MYAPP_API_KEY → matches _KEY)
786    SENSITIVE_ENV_SUFFIXES
787        .iter()
788        .any(|suffix| key_upper.ends_with(suffix))
789}
790
791/// Builds a sanitized environment for shell execution.
792///
793/// Strips environment variables matching sensitive patterns (prefix-based
794/// and suffix-based). Preserves safe system variables and Runtimo's own
795/// opt-in feature flags. PATH is always set to the sanitized path
796/// regardless of the incoming environment.
797#[must_use]
798fn sanitized_env() -> Vec<(String, String)> {
799    std::env::vars()
800        .filter(|(key, _)| !is_sensitive_env_var(key))
801        .collect()
802}
803
804/// Returns `true` when a command dumps all environment variables.
805///
806/// Shell builtins that expose the full environment (possibly including
807/// secrets) are flagged as dangerous. This catches:
808/// - `env` / `printenv` — print all env vars
809/// - `set` — shell builtin that dumps vars+functions
810/// - `export` / `export -p` — print exported vars
811/// - `declare -p` / `typeset -p` — bash/ksh var dump
812/// - `compgen -v` — bash variable name completion
813#[must_use]
814pub fn is_env_dumping_command(cmd: &str) -> bool {
815    let cmd_lower = cmd.to_lowercase().trim().to_string();
816    // Also check the detokenized version
817    let detok_lower = detokenize_command(&cmd_lower);
818
819    let env_dumpers: &[&str] = &[
820        "env", "printenv", "set", "export", "declare", "typeset", "compgen",
821    ];
822
823    for dumper in env_dumpers {
824        // Check original command
825        if command_matches(&cmd_lower, &[dumper]) {
826            return true;
827        }
828        // Check detokenized version (catches "e'n'v" etc.)
829        if command_matches(&detok_lower, &[dumper]) {
830            return true;
831        }
832    }
833    false
834}
835
836// ── F-014: Path-aware ShellExec ────────────────────────────────────────────
837
838/// Checks whether a resolved path is within one of the allowed prefixes.
839///
840/// Uses the same prefix-matching logic as [`crate::validation::path::path_in_prefix`]
841/// but operates on string paths without requiring filesystem existence.
842///
843/// Empty prefixes are skipped to prevent matching everything via
844/// `format!("{}/", "")` which produces `"/"` — a path that matches
845/// every absolute path (N-014 defense-in-depth).
846#[must_use]
847fn is_path_within_allowed(path_str: &str, allowed: &[String]) -> bool {
848    allowed
849        .iter()
850        .filter(|prefix| !prefix.is_empty())
851        .any(|prefix| path_str == prefix || path_str.starts_with(&format!("{}/", prefix)))
852}
853
854/// Expands `$VAR` and `${VAR}` references in a path-like token using the
855/// current process environment. Returns the expanded path, or the original
856/// token if no variables were found or expansion failed.
857///
858/// # GAP-08
859///
860/// This catches paths like `$HOME/.ssh/id_ed25519` and `${HOME}/.aws/credentials`
861/// that would otherwise be skipped by path validation because they don't start
862/// with `/` or `~/`.
863#[must_use]
864fn expand_shell_vars(token: &str) -> String {
865    let mut result = String::with_capacity(token.len());
866    let mut chars = token.chars().peekable();
867    let mut expanded_any = false;
868
869    while let Some(&c) = chars.peek() {
870        if c == '$' {
871            chars.next(); // consume $
872                          // Check for ${VAR} syntax
873            if chars.peek() == Some(&'{') {
874                chars.next(); // consume {
875                let mut var_name = String::new();
876                while let Some(&ch) = chars.peek() {
877                    if ch == '}' {
878                        chars.next(); // consume }
879                        break;
880                    }
881                    var_name.push(ch);
882                    chars.next();
883                }
884                // Expand the variable
885                if let Ok(value) = std::env::var(&var_name) {
886                    result.push_str(&value);
887                    expanded_any = true;
888                } else {
889                    // Variable not set — keep the reference as-is for blocklist
890                    result.push('$');
891                    result.push('{');
892                    result.push_str(&var_name);
893                    result.push('}');
894                }
895            } else {
896                // $VAR syntax (no braces) — read until non-alphanumeric or _
897                let mut var_name = String::new();
898                while let Some(&ch) = chars.peek() {
899                    if ch.is_alphanumeric() || ch == '_' {
900                        var_name.push(ch);
901                        chars.next();
902                    } else {
903                        break;
904                    }
905                }
906                if var_name.is_empty() {
907                    // Lone $ (e.g., end of string or $ followed by non-name char)
908                    result.push('$');
909                } else if let Ok(value) = std::env::var(&var_name) {
910                    result.push_str(&value);
911                    expanded_any = true;
912                } else {
913                    // Variable not set — keep reference
914                    result.push('$');
915                    result.push_str(&var_name);
916                }
917            }
918        } else {
919            result.push(c);
920            chars.next();
921        }
922    }
923
924    if expanded_any {
925        result
926    } else {
927        token.to_string()
928    }
929}
930
931/// Scans a shell command for path references and validates them against
932/// the allowed prefixes from configuration.
933///
934/// # What it catches
935///
936/// - Absolute paths: `cat /etc/passwd`, `ls /root/.ssh/`
937/// - Tilde-expanded paths: `cat ~/.ssh/id_ed25519`, `ls ~/.aws/credentials`
938/// - Paths after I/O redirects: `> /etc/cron.d/evil`, `< /root/secrets`
939///
940/// # What it does NOT catch (GAP-09, GAP-10)
941///
942/// - Command substitution paths: `cat $(find /etc -name shadow)` — blocked by GAP-05
943/// - Backtick paths: `` cat `/etc/passwd` `` — blocked by GAP-05
944/// - Inline redirect paths: `echo evil >/etc/cron.d/backdoor` — handled by GAP-10
945///
946/// These are documented as GAP-09 through GAP-12 and require a shell-AST
947/// based approach for comprehensive coverage.
948///
949/// # Returns
950///
951/// `None` if all detectable paths are within allowed directories.
952/// `Some(reason)` with a human-readable description of the blocked path.
953#[must_use]
954fn check_command_paths(cmd: &str) -> Option<String> {
955    let allowed = RuntimoConfig::get_allowed_prefixes();
956    let detok = detokenize_command(cmd);
957
958    // Scan each whitespace-separated token
959    for token in detok.split_whitespace() {
960        // Strip surrounding quotes, backticks, commas (common in arg lists)
961        let path = token.trim_matches(|c: char| c == '"' || c == '\'' || c == '`' || c == ',');
962
963        // Skip empty or non-path tokens
964        if path.is_empty() || path.len() < 2 {
965            continue;
966        }
967
968        // Skip "--flag" style arguments
969        if path.starts_with('-') {
970            continue;
971        }
972
973        // ── GAP-10: Strip I/O redirect operators ──
974        // Tokens like ">/etc/cron.d/evil" have the redirect operator
975        // glued to the path. Strip the operator prefix and check the
976        // remaining path. Handles: > >> < 2> 1> &> 2>> &>>
977        let path_without_redirect = path
978            .trim_start_matches("&>>")
979            .trim_start_matches("2>>")
980            .trim_start_matches("1>>")
981            .trim_start_matches("&>")
982            .trim_start_matches("2>")
983            .trim_start_matches("1>")
984            .trim_start_matches(">>")
985            .trim_start_matches('>')
986            .trim_start_matches('<');
987
988        // If we stripped something and the remainder starts with /, use it
989        let path = if path_without_redirect != path
990            && (path_without_redirect.starts_with('/') || path_without_redirect.starts_with("~/"))
991        {
992            path_without_redirect
993        } else {
994            path
995        };
996
997        // Skip shell variable assignments: VAR=value
998        if path.contains('=') && !path.starts_with('/') && !path.starts_with("~/") {
999            continue;
1000        }
1001
1002        // ── GAP-08: Expand $VAR and ${VAR} references ──
1003        // Shell variables like $HOME/.ssh/id_ed25519 don't start with /
1004        // or ~/, so they'd normally be skipped. Expand them against the
1005        // current environment and check the resolved path.
1006        let resolved = if path.contains('$') {
1007            let expanded = expand_shell_vars(path);
1008            if expanded == path {
1009                // No variables were expanded; token might just contain
1010                // a literal $ that isn't a var reference. Skip.
1011                continue;
1012            }
1013            // After expansion, check if the resolved path is absolute
1014            if expanded.starts_with('/') {
1015                // Strip trailing shell operators
1016                let clean = expanded.trim_end_matches(|c: char| {
1017                    c == ';' || c == '|' || c == '&' || c == '>' || c == '<'
1018                });
1019                clean.to_string()
1020            } else {
1021                // Expanded but not absolute — skip (e.g., echo $VAR)
1022                continue;
1023            }
1024        } else if path.starts_with("~/") {
1025            match std::env::var("HOME") {
1026                Ok(home) => {
1027                    let mut home_path = home.trim_end_matches('/').to_string();
1028                    home_path.push_str(&path[1..]); // path[1..] = "/rest/of/path"
1029                    home_path
1030                }
1031                Err(_) => continue, // skip if HOME is unset (rare)
1032            }
1033        } else if path.starts_with('/') {
1034            // Strip trailing shell operators: semicolon, pipe, redirect, ampersand
1035            // e.g. "/etc/passwd;" → "/etc/passwd"
1036            let clean_end = path.trim_end_matches(|c: char| {
1037                c == ';' || c == '|' || c == '&' || c == '>' || c == '<'
1038            });
1039            clean_end.to_string()
1040        } else if path.starts_with('.') {
1041            // ── GAP-13: Resolve relative paths against CWD ──
1042            // `../../etc/passwd` from /tmp resolves to /etc/passwd.
1043            // Normalize the path (join with CWD, resolve ".." and ".")
1044            // without requiring filesystem existence.
1045            let Ok(cwd) = std::env::current_dir() else {
1046                continue;
1047            };
1048            let joined = cwd.join(path);
1049            // Normalize: remove "." and resolve ".." components
1050            let mut components: Vec<&str> = Vec::new();
1051            for component in joined.components() {
1052                match component {
1053                    std::path::Component::ParentDir => {
1054                        components.pop(); // go up one level
1055                    }
1056                    std::path::Component::Normal(os_str) => {
1057                        if let Some(s) = os_str.to_str() {
1058                            components.push(s);
1059                        }
1060                    }
1061                    std::path::Component::RootDir => {
1062                        // Start fresh from root
1063                        components.clear();
1064                    }
1065                    // CurDir, Prefix — no effect
1066                    _ => {}
1067                }
1068            }
1069            let normalized = format!("/{}", components.join("/"));
1070            // Reject if the normalized path contains ".." (couldn't fully resolve)
1071            if normalized.contains("/../") || normalized.contains("/..") || normalized == "/.." {
1072                return Some(format!(
1073                    "ShellExec blocked: path traversal not allowed: {}",
1074                    path
1075                ));
1076            }
1077            normalized
1078        } else {
1079            continue;
1080        };
1081
1082        // Skip if it's just "/" (root — handled by the blocklist)
1083        if resolved == "/" {
1084            continue;
1085        }
1086
1087        // Reject paths containing ".." traversal (prevents prefix-bypass
1088        // via parent traversal: /home/user/../../etc/passwd)
1089        if resolved.contains("/../") || resolved.contains("/..") || resolved == ".." {
1090            return Some(format!(
1091                "ShellExec blocked: path traversal not allowed: {}",
1092                path
1093            ));
1094        }
1095
1096        // Skip device paths (handled by blocklist: dd, mkfs)
1097        if resolved.starts_with("/dev/") {
1098            continue;
1099        }
1100
1101        // Skip proc/sys filesystem (handled by blocklist)
1102        if resolved.starts_with("/proc/") || resolved.starts_with("/sys/") {
1103            continue;
1104        }
1105
1106        if !is_path_within_allowed(&resolved, &allowed) {
1107            // Don't leak the actual resolved HOME path in error messages
1108            let display_path = if path.starts_with("~/") {
1109                path.to_string()
1110            } else {
1111                resolved
1112            };
1113            return Some(format!(
1114                "ShellExec blocked: path is outside allowed directories: {}",
1115                display_path
1116            ));
1117        }
1118    }
1119
1120    None
1121}
1122
1123#[allow(clippy::arithmetic_side_effects)] // -(pgid) negation is safe for valid PIDs
1124fn wait_with_timeout(child: &mut Child, pgid: u32, timeout_secs: u64) -> WaitResult {
1125    let start = Instant::now();
1126    let timeout = Duration::from_secs(timeout_secs);
1127    let child_pid = child.id();
1128    let stdout_thread = child.stdout.take().map(|stdout| {
1129        thread::spawn(move || {
1130            let mut data = Vec::new();
1131            let _ = stdout.take(MAX_OUTPUT_BYTES as u64).read_to_end(&mut data);
1132            data
1133        })
1134    });
1135    let stderr_thread = child.stderr.take().map(|stderr| {
1136        thread::spawn(move || {
1137            let mut data = Vec::new();
1138            let _ = stderr.take(MAX_OUTPUT_BYTES as u64).read_to_end(&mut data);
1139            data
1140        })
1141    });
1142    let mut last_descendants: Vec<u32>;
1143    loop {
1144        if start.elapsed() > timeout {
1145            // SAFETY: pgid is a valid process group ID from the spawned child; SIGKILL is well-defined;
1146            // pgid as pid_t may wrap on 32-bit but pgid is always within pid_t range
1147            #[allow(clippy::cast_possible_wrap)]
1148            unsafe {
1149                let _ = libc::kill(-(pgid as libc::pid_t), libc::SIGKILL);
1150            }
1151            let killed_descendants = get_all_descendants(child_pid);
1152            let _ = child.wait();
1153            let _ = stdout_thread.map(|h| h.join().unwrap_or_default());
1154            let _ = stderr_thread.map(|h| h.join().unwrap_or_default());
1155            return Err(Error::ExecutionFailed(format!(
1156                "command timed out after {}s (killed {} descendants)",
1157                timeout_secs,
1158                killed_descendants.len()
1159            )));
1160        }
1161        last_descendants = get_all_descendants(child_pid);
1162        match child.try_wait() {
1163            Ok(Some(status)) => {
1164                let stdout_data = stdout_thread
1165                    .map(|h| h.join().unwrap_or_default())
1166                    .unwrap_or_default();
1167                let stderr_data = stderr_thread
1168                    .map(|h| h.join().unwrap_or_default())
1169                    .unwrap_or_default();
1170                return Ok((status, stdout_data, stderr_data, last_descendants));
1171            }
1172            Ok(None) => std::thread::sleep(Duration::from_millis(50)),
1173            Err(e) => return Err(Error::ExecutionFailed(format!("error waiting: {}", e))),
1174        }
1175    }
1176}
1177
1178fn get_direct_children(pid: u32) -> Vec<u32> {
1179    let children_path = format!("/proc/{}/children", pid);
1180    if let Ok(content) = fs::read_to_string(&children_path) {
1181        content
1182            .split_whitespace()
1183            .filter_map(|s| s.parse::<u32>().ok())
1184            .collect()
1185    } else {
1186        Vec::new()
1187    }
1188}
1189
1190fn get_all_descendants(pid: u32) -> Vec<u32> {
1191    let mut descendants = Vec::new();
1192    let mut stack = vec![pid];
1193    let mut visited = std::collections::HashSet::new();
1194    while let Some(current) = stack.pop() {
1195        if visited.contains(&current) {
1196            continue;
1197        }
1198        visited.insert(current);
1199        let children = get_direct_children(current);
1200        if children.is_empty() {
1201            if let Ok(output) = std::process::Command::new("pgrep")
1202                .arg("-P")
1203                .arg(current.to_string())
1204                .output()
1205            {
1206                if output.status.success() {
1207                    let pgrep_lines = String::from_utf8_lossy(&output.stdout).to_string();
1208                    let pgrep_children = pgrep_lines
1209                        .lines()
1210                        .filter_map(|s| s.trim().parse::<u32>().ok());
1211                    for child in pgrep_children {
1212                        if !visited.contains(&child) {
1213                            descendants.push(child);
1214                            stack.push(child);
1215                        }
1216                    }
1217                    continue;
1218                }
1219            }
1220        }
1221        for child in children {
1222            if !visited.contains(&child) {
1223                descendants.push(child);
1224                stack.push(child);
1225            }
1226        }
1227    }
1228    descendants
1229}
1230
1231/// Capability that executes shell commands with safety guards.
1232///
1233/// Commands are run in the executor's process group with a configurable
1234/// timeout. A blocklist rejects destructive commands (e.g. `rm -rf /`,
1235/// `dd if=/dev/zero of=/dev/sda`). All executions are logged to the WAL.
1236#[allow(clippy::exhaustive_structs)]
1237pub struct ShellExec;
1238
1239impl TypedCapability for ShellExec {
1240    type Args = ShellExecArgs;
1241
1242    fn name(&self) -> &'static str {
1243        "ShellExec"
1244    }
1245    fn description(&self) -> &'static str {
1246        "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."
1247    }
1248    fn schema(&self) -> Value {
1249        serde_json::json!({
1250            "type": "object",
1251            "properties": {
1252                "cmd": { "type": "string", "description": "Command to execute via sh -c (max 65536 bytes)", "minLength": 1, "maxLength": 65536 },
1253                "timeout_secs": { "type": "integer", "minimum": 1, "maximum": 300 },
1254                "cwd": { "type": "string" },
1255                "stdin": { "type": "string" }
1256            },
1257            "required": ["cmd"]
1258        })
1259    }
1260    fn execute(
1261        &self,
1262        args: ShellExecArgs,
1263        ctx: &Context,
1264    ) -> std::result::Result<Output, CapabilityError> {
1265        // Timeout from JSON args, falling back to default
1266        // Enforce range: minimum 1, maximum 300 (matching schema declaration)
1267        if let Some(secs) = args.timeout_secs {
1268            if !(1..=300).contains(&secs) {
1269                return Err(CapabilityError::InvalidArgs(format!(
1270                    "timeout_secs must be between 1 and 300, got {}",
1271                    secs
1272                )));
1273            }
1274        }
1275        let timeout = args.timeout_secs.unwrap_or(DEFAULT_TIMEOUT_SECS);
1276
1277        let max_cmd_len: usize = 65536;
1278
1279        // Reject empty or whitespace-only commands
1280        if args.cmd.trim().is_empty() {
1281            return Err(CapabilityError::InvalidArgs(
1282                "command is empty or contains only whitespace".into(),
1283            ));
1284        }
1285
1286        // Enforce maximum command length (prevent oversized CLI args)
1287        if args.cmd.len() > max_cmd_len {
1288            return Err(CapabilityError::InvalidArgs(format!(
1289                "command too long ({} bytes, max {})",
1290                args.cmd.len(),
1291                max_cmd_len
1292            )));
1293        }
1294
1295        // F-013: Blocklist check (original + detokenized) — runs BEFORE dry_run
1296        // so dangerous commands are rejected even in dry-run mode (F-017).
1297        if let Some(reason) = is_dangerous_command(&args.cmd) {
1298            return Err(CapabilityError::PermissionDenied(format!(
1299                "dangerous command blocked: {}",
1300                reason
1301            )));
1302        }
1303
1304        // F-015: Block env-dumping commands (checked in is_dangerous_command
1305        // but also here as defense-in-depth in case the blocklist evolves)
1306        if !network_enabled() && is_network_command(&args.cmd) {
1307            return Err(CapabilityError::PermissionDenied(
1308                "network commands blocked — set RUNTIMO_ENABLE_NETWORK=1 to enable".into(),
1309            ));
1310        }
1311
1312        // N-009: Block interpreter commands (checked in is_dangerous_command
1313        // would be redundant — interpreters are not "dangerous" in the blocklist
1314        // sense, they are gated capabilities. This is the gating layer.)
1315        if !interpreters_enabled() && is_interpreter_command(&args.cmd) {
1316            return Err(CapabilityError::PermissionDenied(
1317                "interpreter commands blocked — set RUNTIMO_ENABLE_INTERPRETERS=1 to enable".into(),
1318            ));
1319        }
1320
1321        // F-014: Path restriction check — scan for paths outside allowed prefixes
1322        if let Some(reason) = check_command_paths(&args.cmd) {
1323            return Err(CapabilityError::PermissionDenied(reason));
1324        }
1325
1326        // Dry-run check AFTER security validation — ensures dangerous commands
1327        // are blocked even in dry-run mode (F-017: dry_run must not bypass security)
1328        if ctx.dry_run {
1329            let mut out = Output::ok("DRY RUN".into());
1330            out.data = Some(serde_json::json!({ "cmd": &args.cmd, "dry_run": true }));
1331            return Ok(out);
1332        }
1333
1334        let mut cmd = Command::new("sh");
1335        // PATH sanitization: limit executable resolution to trusted system dirs.
1336        // This is defense-in-depth — the blocklist catches known-dangerous
1337        // commands, but this prevents invocation of custom binaries in
1338        // non-standard locations.
1339        cmd.env("PATH", "/usr/local/bin:/usr/bin:/bin");
1340
1341        // F-015: Sanitized environment — strip sensitive env vars before spawning.
1342        // Only safe system variables survive; secrets (API keys, tokens, passwords)
1343        // are removed. RUNTIMO_ENABLE_NETWORK is explicitly preserved in the safe list.
1344        let safe_env = sanitized_env();
1345        for (key, value) in &safe_env {
1346            // PATH is set explicitly above; skip to avoid duplicate
1347            if key == "PATH" {
1348                continue;
1349            }
1350            cmd.env(key, value);
1351        }
1352
1353        cmd.arg("-c").arg(&args.cmd);
1354        if let Some(cwd) = &args.cwd {
1355            let path_ctx = PathContext {
1356                require_exists: true,
1357                require_file: false,
1358                ..Default::default()
1359            };
1360            let cwd_path = validate_path(cwd, &path_ctx)
1361                .map_err(|e| CapabilityError::PermissionDenied(format!("invalid cwd: {}", e)))?;
1362            cmd.current_dir(cwd_path);
1363        }
1364        let mut child = cmd
1365            .process_group(0)
1366            .stdout(std::process::Stdio::piped())
1367            .stderr(std::process::Stdio::piped())
1368            .stdin(if args.stdin.is_some() {
1369                std::process::Stdio::piped()
1370            } else {
1371                std::process::Stdio::null()
1372            })
1373            .spawn()
1374            .map_err(|e| {
1375                CapabilityError::Io(std::io::Error::other(format!("failed to spawn: {}", e)))
1376            })?;
1377        let child_pid = child.id();
1378        let pgid = child_pid;
1379        if let Some(ref stdin_content) = args.stdin {
1380            if stdin_content.len() > MAX_STDIN_BYTES {
1381                return Err(CapabilityError::InvalidArgs("stdin too large".into()));
1382            }
1383            if let Some(mut stdin_pipe) = child.stdin.take() {
1384                let _ = stdin_pipe.write_all(stdin_content.as_bytes());
1385            }
1386        }
1387        let (exit_status, stdout, stderr, _descendants) =
1388            wait_with_timeout(&mut child, pgid, timeout)
1389                .map_err(|e| CapabilityError::Internal(e.to_string()))?;
1390        let stdout_str = String::from_utf8_lossy(&stdout).to_string();
1391        let stderr_str = String::from_utf8_lossy(&stderr).to_string();
1392        let success = exit_status.success();
1393
1394        let mut out = if success {
1395            Output::ok("completed".into())
1396        } else {
1397            Output::error(
1398                format!("exit code {}", exit_status.code().unwrap_or(-1)),
1399                format!("exit code {}", exit_status.code().unwrap_or(-1)),
1400            )
1401        };
1402        out.data = Some(
1403            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 }),
1404        );
1405        Ok(out)
1406    }
1407}
1408
1409#[cfg(test)]
1410mod tests {
1411    use super::*;
1412    use crate::capability::Capability;
1413    use std::time::Instant;
1414    #[test]
1415    fn executes_uptime() {
1416        let r = Capability::execute(
1417            &ShellExec,
1418            &serde_json::json!({"cmd": "uptime"}),
1419            &Context {
1420                dry_run: false,
1421                job_id: "test".into(),
1422                working_dir: std::env::temp_dir(),
1423            },
1424        )
1425        .unwrap();
1426        assert_eq!(r.status, "ok");
1427    }
1428    #[test]
1429    fn pipes_work() {
1430        let r = Capability::execute(
1431            &ShellExec,
1432            &serde_json::json!({"cmd": "echo hi | cat"}),
1433            &Context {
1434                dry_run: false,
1435                job_id: "test".into(),
1436                working_dir: std::env::temp_dir(),
1437            },
1438        )
1439        .unwrap();
1440        assert_eq!(r.status, "ok");
1441        assert!(r.data.as_ref().unwrap()["stdout"]
1442            .as_str()
1443            .unwrap()
1444            .contains("hi"));
1445    }
1446    #[test]
1447    fn chaining_works() {
1448        let r = Capability::execute(
1449            &ShellExec,
1450            &serde_json::json!({"cmd": "echo a && echo b"}),
1451            &Context {
1452                dry_run: false,
1453                job_id: "test".into(),
1454                working_dir: std::env::temp_dir(),
1455            },
1456        )
1457        .unwrap();
1458        assert_eq!(r.status, "ok");
1459    }
1460    #[test]
1461    fn blocks_dangerous() {
1462        assert!(Capability::execute(
1463            &ShellExec,
1464            &serde_json::json!({"cmd": "mkfs"}),
1465            &Context {
1466                dry_run: false,
1467                job_id: "test".into(),
1468                working_dir: std::env::temp_dir(),
1469            }
1470        )
1471        .is_err());
1472    }
1473    #[test]
1474    fn blocks_recursive_flag() {
1475        // rm --recursive (long form) should be caught like -rf
1476        assert!(Capability::execute(
1477            &ShellExec,
1478            &serde_json::json!({"cmd": "rm --recursive /home"}),
1479            &Context {
1480                dry_run: false,
1481                job_id: "test".into(),
1482                working_dir: std::env::temp_dir(),
1483            }
1484        )
1485        .is_err());
1486    }
1487    #[test]
1488    fn blocks_rm_rf_root() {
1489        // rm -rf / should always be blocked regardless of context
1490        assert!(Capability::execute(
1491            &ShellExec,
1492            &serde_json::json!({"cmd": "rm -rf /"}),
1493            &Context {
1494                dry_run: false,
1495                job_id: "test".into(),
1496                working_dir: std::env::temp_dir(),
1497            }
1498        )
1499        .is_err());
1500    }
1501    #[test]
1502    fn blocks_rm_no_preserve_root() {
1503        assert!(Capability::execute(
1504            &ShellExec,
1505            &serde_json::json!({"cmd": "rm --no-preserve-root -rf /"}),
1506            &Context {
1507                dry_run: false,
1508                job_id: "test".into(),
1509                working_dir: std::env::temp_dir(),
1510            }
1511        )
1512        .is_err());
1513    }
1514    #[test]
1515    fn blocks_ownership_commands() {
1516        for cmd in &["chown root /tmp/x", "chgrp staff /tmp/x"] {
1517            assert!(
1518                Capability::execute(
1519                    &ShellExec,
1520                    &serde_json::json!({"cmd": cmd}),
1521                    &Context {
1522                        dry_run: false,
1523                        job_id: "test".into(),
1524                        working_dir: std::env::temp_dir(),
1525                    }
1526                )
1527                .is_err(),
1528                "should block: {}",
1529                cmd
1530            );
1531        }
1532    }
1533    #[test]
1534    fn blocks_mount_commands() {
1535        for cmd in &["mount /dev/sda1 /mnt", "umount /mnt"] {
1536            assert!(
1537                Capability::execute(
1538                    &ShellExec,
1539                    &serde_json::json!({"cmd": cmd}),
1540                    &Context {
1541                        dry_run: false,
1542                        job_id: "test".into(),
1543                        working_dir: std::env::temp_dir(),
1544                    }
1545                )
1546                .is_err(),
1547                "should block: {}",
1548                cmd
1549            );
1550        }
1551    }
1552    #[test]
1553    fn blocks_firewall_commands() {
1554        for cmd in &["iptables -L", "nft list ruleset"] {
1555            assert!(
1556                Capability::execute(
1557                    &ShellExec,
1558                    &serde_json::json!({"cmd": cmd}),
1559                    &Context {
1560                        dry_run: false,
1561                        job_id: "test".into(),
1562                        working_dir: std::env::temp_dir(),
1563                    }
1564                )
1565                .is_err(),
1566                "should block: {}",
1567                cmd
1568            );
1569        }
1570    }
1571    #[test]
1572    fn blocks_network_commands_by_default() {
1573        // Ensure RUNTIMO_ENABLE_NETWORK is not set
1574        std::env::remove_var("RUNTIMO_ENABLE_NETWORK");
1575        for cmd in &[
1576            "curl http://example.com",
1577            "wget http://example.com",
1578            "nc example.com 80",
1579        ] {
1580            assert!(
1581                Capability::execute(
1582                    &ShellExec,
1583                    &serde_json::json!({"cmd": cmd}),
1584                    &Context {
1585                        dry_run: false,
1586                        job_id: "test".into(),
1587                        working_dir: std::env::temp_dir(),
1588                    }
1589                )
1590                .is_err(),
1591                "should block network cmd: {}",
1592                cmd
1593            );
1594        }
1595    }
1596    #[test]
1597    fn allows_network_commands_when_enabled() {
1598        std::env::set_var("RUNTIMO_ENABLE_NETWORK", "1");
1599        // curl --version should work (non-destructive)
1600        let r = Capability::execute(
1601            &ShellExec,
1602            &serde_json::json!({"cmd": "curl --version"}),
1603            &Context {
1604                dry_run: false,
1605                job_id: "test".into(),
1606                working_dir: std::env::temp_dir(),
1607            },
1608        );
1609        std::env::remove_var("RUNTIMO_ENABLE_NETWORK");
1610        // May fail if curl not installed, but should NOT fail with "network commands blocked"
1611        match r {
1612            Ok(o) => assert_eq!(o.status, "ok", "curl --version should succeed when enabled"),
1613            Err(e) => {
1614                let msg = e.to_string();
1615                assert!(
1616                    !msg.contains("network commands blocked"),
1617                    "should NOT block network when RUNTIMO_ENABLE_NETWORK=1, got: {}",
1618                    msg
1619                );
1620            }
1621        }
1622    }
1623    #[test]
1624    fn enforces_timeout() {
1625        let s = Instant::now();
1626        assert!(Capability::execute(
1627            &ShellExec,
1628            &serde_json::json!({"cmd": "sleep 5", "timeout_secs": 1}),
1629            &Context {
1630                dry_run: false,
1631                job_id: "test".into(),
1632                working_dir: std::env::temp_dir(),
1633            }
1634        )
1635        .is_err());
1636        assert!(s.elapsed().as_secs() < 3);
1637    }
1638
1639    // ── F-013: Detokenizer + Shell Quoting Bypass Tests ─────────────────
1640
1641    // ── GAP-01: ANSI-C quoting ($'...') tests ──────────────────────────
1642
1643    #[test]
1644    fn detokenize_ansi_c_tab_expansion() {
1645        // $'rm\t-rf\t/' → ANSI-C expands \t to tab, which we convert to space
1646        let detok = detokenize_command("$'rm\\t-rf\\t/'");
1647        // After ANSI-C expansion: rm -rf / (with spaces where tabs were)
1648        assert!(detok.contains("rm"));
1649        assert!(detok.contains("-rf"));
1650        assert!(detok.contains('/'));
1651    }
1652
1653    #[test]
1654    fn detokenize_ansi_c_plain_content() {
1655        // $'rm -rf /' → rm -rf / (plain content, no escapes)
1656        let detok = detokenize_command("$'rm -rf /'");
1657        assert!(detok.contains("rm -rf /"));
1658    }
1659
1660    #[test]
1661    fn detokenize_ansi_c_hex_escape() {
1662        // $'\x72\x6d' → rm (hex escape for each character)
1663        let detok = detokenize_command("$'\\x72\\x6d'");
1664        assert!(
1665            detok.contains("rm"),
1666            "Hex-encoded 'rm' should decode, got: {:?}",
1667            detok
1668        );
1669    }
1670
1671    #[test]
1672    fn detokenize_ansi_c_unicode_escape() {
1673        // $'\u0072\u006d' → rm (unicode escape for each character)
1674        let detok = detokenize_command("$'\\u0072\\u006d'");
1675        assert!(
1676            detok.contains("rm"),
1677            "Unicode-encoded 'rm' should decode, got: {:?}",
1678            detok
1679        );
1680    }
1681
1682    #[test]
1683    fn detokenize_ansi_c_octal_escape() {
1684        // $'\162\155' → rm (octal: 162=0x72='r', 155=0x6d='m')
1685        let detok = detokenize_command("$'\\162\\155'");
1686        assert!(
1687            detok.contains("rm"),
1688            "Octal-encoded 'rm' should decode, got: {:?}",
1689            detok
1690        );
1691    }
1692
1693    #[test]
1694    fn detokenize_ansi_c_newline_expansion() {
1695        // $'rm\n-rf\n/' → newline becomes space for token visibility
1696        let detok = detokenize_command("$'rm\\n-rf\\n/'");
1697        assert!(detok.contains("rm"));
1698        assert!(detok.contains("-rf"));
1699    }
1700
1701    #[test]
1702    fn detokenize_ansi_c_combined_escapes() {
1703        // $'m\\x6bfs' → mkfs (literal m + hex k + literal fs)
1704        let detok = detokenize_command("$'m\\x6bfs'");
1705        assert!(
1706            detok.contains("mkfs"),
1707            "Should decode to mkfs, got: {:?}",
1708            detok
1709        );
1710    }
1711
1712    #[test]
1713    fn blocks_rm_via_ansi_c_bypass() {
1714        // GAP-01: $'rm\t-rf\t/' should be blocked
1715        let err = Capability::execute(
1716            &ShellExec,
1717            &serde_json::json!({"cmd": "$'rm\\t-rf\\t/'"}),
1718            &Context {
1719                dry_run: false,
1720                job_id: "test".into(),
1721                working_dir: std::env::temp_dir(),
1722            },
1723        )
1724        .unwrap_err();
1725        let msg = format!("{}", err);
1726        assert!(
1727            msg.contains("recursive rm") || msg.contains("dangerous command blocked"),
1728            "Should block ANSI-C quoted rm, got: {}",
1729            msg
1730        );
1731    }
1732
1733    #[test]
1734    fn blocks_rm_via_ansi_c_hex_bypass() {
1735        // GAP-06: $'\x72\x6d' -rf / should be blocked
1736        let err = Capability::execute(
1737            &ShellExec,
1738            &serde_json::json!({"cmd": "$'\\x72\\x6d' -rf /"}),
1739            &Context {
1740                dry_run: false,
1741                job_id: "test".into(),
1742                working_dir: std::env::temp_dir(),
1743            },
1744        )
1745        .unwrap_err();
1746        assert!(
1747            format!("{}", err).contains("recursive rm")
1748                || format!("{}", err).contains("rm command blocked"),
1749            "Should block hex-encoded rm bypass"
1750        );
1751    }
1752
1753    // ── GAP-14: chmod quoting bypass test ──────────────────────────────
1754
1755    #[test]
1756    fn blocks_chmod_via_quote_bypass() {
1757        // c"hmod" 777 / should be blocked (detokenized to chmod 777 /)
1758        let err = Capability::execute(
1759            &ShellExec,
1760            &serde_json::json!({"cmd": "c\"hmod\" 777 /"}),
1761            &Context {
1762                dry_run: false,
1763                job_id: "test".into(),
1764                working_dir: std::env::temp_dir(),
1765            },
1766        )
1767        .unwrap_err();
1768        assert!(
1769            format!("{}", err).contains("chmod"),
1770            "Should block c\"hmod\" 777 / (quoted chmod bypass)"
1771        );
1772    }
1773
1774    // ── Original detokenizer tests ──────────────────────────────────────
1775
1776    // ── GAP-02: Multi-pass + backslash-newline tests ───────────────────
1777
1778    #[test]
1779    fn detokenize_backslash_newline_continuation() {
1780        // r\<newline>m -rf / → should join as rm -rf /
1781        let cmd_with_newline = "r\\\nm -rf /";
1782        let detok = detokenize_command(cmd_with_newline);
1783        assert!(
1784            detok.contains("rm"),
1785            "Backslash-newline should be stripped, got: {:?}",
1786            detok
1787        );
1788        assert!(
1789            detok.contains("rm -rf /"),
1790            "Should rejoin tokens across newline, got: {:?}",
1791            detok
1792        );
1793    }
1794
1795    #[test]
1796    fn blocks_rm_via_backslash_newline_bypass() {
1797        // GAP-02: r\<newline>m -rf / should be blocked
1798        let err = Capability::execute(
1799            &ShellExec,
1800            &serde_json::json!({"cmd": "r\\\nm -rf /"}),
1801            &Context {
1802                dry_run: false,
1803                job_id: "test".into(),
1804                working_dir: std::env::temp_dir(),
1805            },
1806        )
1807        .unwrap_err();
1808        assert!(
1809            format!("{}", err).contains("recursive rm")
1810                || format!("{}", err).contains("rm command blocked"),
1811            "Should block backslash-newline rm bypass"
1812        );
1813    }
1814
1815    #[test]
1816    fn detokenize_multi_pass_stability() {
1817        // Triple-nested quotes should be fully stripped
1818        let detok = detokenize_command("\"'rm'\" -rf /");
1819        // After multi-pass: 'rm' → rm (single quotes stripped second pass)
1820        assert!(
1821            detok.contains("rm"),
1822            "Multi-pass should converge, got: {:?}",
1823            detok
1824        );
1825    }
1826
1827    #[test]
1828    fn detokenize_roundtrip_idempotent() {
1829        // After detokenization, running again should produce the same result
1830        let cmd = "r\"m\" -rf /";
1831        let detok1 = detokenize_command(cmd);
1832        let detok2 = detokenize_command(&detok1);
1833        assert_eq!(detok1, detok2, "Detokenization should be idempotent");
1834    }
1835
1836    // ── GAP-03: Heredoc tests ──────────────────────────────────────────
1837
1838    #[test]
1839    fn blocks_heredoc() {
1840        // <<EOF followed by any content should be blocked
1841        let err = Capability::execute(
1842            &ShellExec,
1843            &serde_json::json!({"cmd": "cat <<EOF\nevil\nEOF"}),
1844            &Context {
1845                dry_run: false,
1846                job_id: "test".into(),
1847                working_dir: std::env::temp_dir(),
1848            },
1849        )
1850        .unwrap_err();
1851        let msg = format!("{}", err);
1852        assert!(
1853            msg.contains("heredoc"),
1854            "Should block heredoc (<<), got: {}",
1855            msg
1856        );
1857    }
1858
1859    #[test]
1860    fn blocks_herestring() {
1861        // <<< is a herestring, also blocked
1862        let err = Capability::execute(
1863            &ShellExec,
1864            &serde_json::json!({"cmd": "cat <<<\"hello\""}),
1865            &Context {
1866                dry_run: false,
1867                job_id: "test".into(),
1868                working_dir: std::env::temp_dir(),
1869            },
1870        )
1871        .unwrap_err();
1872        assert!(
1873            format!("{}", err).contains("heredoc"),
1874            "Should block herestring (<<<)"
1875        );
1876    }
1877
1878    #[test]
1879    fn blocks_heredoc_via_quote_bypass() {
1880        // << via quoting bypass: <"<"
1881        let err = Capability::execute(
1882            &ShellExec,
1883            &serde_json::json!({"cmd": "<\"<\"EOF\nevil\nEOF"}),
1884            &Context {
1885                dry_run: false,
1886                job_id: "test".into(),
1887                working_dir: std::env::temp_dir(),
1888            },
1889        )
1890        .unwrap_err();
1891        assert!(
1892            format!("{}", err).contains("heredoc"),
1893            "Should block quoted heredoc bypass"
1894        );
1895    }
1896
1897    // ── GAP-04: Process substitution tests ─────────────────────────────
1898
1899    #[test]
1900    fn blocks_process_substitution_input() {
1901        // <(curl ...) process substitution should be blocked
1902        let err = Capability::execute(
1903            &ShellExec,
1904            &serde_json::json!({"cmd": "diff <(curl http://evil) <(ls)"}),
1905            &Context {
1906                dry_run: false,
1907                job_id: "test".into(),
1908                working_dir: std::env::temp_dir(),
1909            },
1910        )
1911        .unwrap_err();
1912        assert!(
1913            format!("{}", err).contains("process substitution"),
1914            "Should block <( ) process substitution"
1915        );
1916    }
1917
1918    #[test]
1919    fn blocks_process_substitution_output() {
1920        // >(tee /etc/cron.d/evil) process substitution should be blocked
1921        let err = Capability::execute(
1922            &ShellExec,
1923            &serde_json::json!({"cmd": "echo evil > >(tee /etc/cron.d/backdoor)"}),
1924            &Context {
1925                dry_run: false,
1926                job_id: "test".into(),
1927                working_dir: std::env::temp_dir(),
1928            },
1929        )
1930        .unwrap_err();
1931        assert!(
1932            format!("{}", err).contains("process substitution"),
1933            "Should block >( ) process substitution"
1934        );
1935    }
1936
1937    // ── GAP-05: Command substitution tests ─────────────────────────────
1938
1939    #[test]
1940    fn blocks_command_substitution_dollar_paren() {
1941        // $(echo rm) should be blocked
1942        let err = Capability::execute(
1943            &ShellExec,
1944            &serde_json::json!({"cmd": "$(echo rm) -rf /"}),
1945            &Context {
1946                dry_run: false,
1947                job_id: "test".into(),
1948                working_dir: std::env::temp_dir(),
1949            },
1950        )
1951        .unwrap_err();
1952        assert!(
1953            format!("{}", err).contains("command substitution"),
1954            "Should block $( ) command substitution"
1955        );
1956    }
1957
1958    #[test]
1959    fn blocks_command_substitution_backtick() {
1960        // `echo rm` backtick substitution should be blocked
1961        let err = Capability::execute(
1962            &ShellExec,
1963            &serde_json::json!({"cmd": "`echo rm` -rf /"}),
1964            &Context {
1965                dry_run: false,
1966                job_id: "test".into(),
1967                working_dir: std::env::temp_dir(),
1968            },
1969        )
1970        .unwrap_err();
1971        assert!(
1972            format!("{}", err).contains("command substitution"),
1973            "Should block backtick command substitution"
1974        );
1975    }
1976
1977    #[test]
1978    fn blocks_command_substitution_in_double_quotes() {
1979        // "$(echo rm) -rf /" — substitution inside double quotes
1980        let err = Capability::execute(
1981            &ShellExec,
1982            &serde_json::json!({"cmd": "\"$(echo rm)\" -rf /"}),
1983            &Context {
1984                dry_run: false,
1985                job_id: "test".into(),
1986                working_dir: std::env::temp_dir(),
1987            },
1988        )
1989        .unwrap_err();
1990        assert!(
1991            format!("{}", err).contains("command substitution"),
1992            "Should block $( ) even inside double quotes"
1993        );
1994    }
1995
1996    // ── Original detokenizer tests ──────────────────────────────────────
1997
1998    #[test]
1999    fn detokenize_strips_double_quotes() {
2000        // r"m" -rf / → rm -rf /
2001        assert_eq!(detokenize_command("r\"m\" -rf /"), "rm -rf /");
2002    }
2003
2004    #[test]
2005    fn detokenize_strips_single_quotes() {
2006        // 'r''m' -rf / → rm -rf /
2007        assert_eq!(detokenize_command("'r''m' -rf /"), "rm -rf /");
2008    }
2009
2010    #[test]
2011    fn detokenize_strips_backslash() {
2012        // r\m -rf / → rm -rf /
2013        assert_eq!(detokenize_command("r\\m -rf /"), "rm -rf /");
2014    }
2015
2016    #[test]
2017    fn detokenize_mixed_quotes() {
2018        // r"m" -r"f" → rm -rf
2019        assert_eq!(detokenize_command("r\"m\" -r\"f\""), "rm -rf");
2020    }
2021
2022    #[test]
2023    fn detokenize_preserves_non_quoted() {
2024        // Normal commands pass through unchanged
2025        assert_eq!(detokenize_command("echo hello"), "echo hello");
2026        assert_eq!(detokenize_command("ls -la /tmp"), "ls -la /tmp");
2027    }
2028
2029    #[test]
2030    fn blocks_rm_via_double_quote_bypass() {
2031        // F-013: r"m" -rf / should be blocked (was NOT previously)
2032        let err = Capability::execute(
2033            &ShellExec,
2034            &serde_json::json!({"cmd": "r\"m\" -rf /"}),
2035            &Context {
2036                dry_run: false,
2037                job_id: "test".into(),
2038                working_dir: std::env::temp_dir(),
2039            },
2040        )
2041        .unwrap_err();
2042        let msg = format!("{}", err);
2043        assert!(
2044            msg.contains("dangerous command blocked") || msg.contains("recursive rm"),
2045            "Should block r\"m\" -rf /, got: {}",
2046            msg
2047        );
2048    }
2049
2050    #[test]
2051    fn blocks_rm_via_single_quote_bypass() {
2052        // F-013: 'r''m' -rf / should be blocked
2053        let err = Capability::execute(
2054            &ShellExec,
2055            &serde_json::json!({"cmd": "'r''m' -rf /"}),
2056            &Context {
2057                dry_run: false,
2058                job_id: "test".into(),
2059                working_dir: std::env::temp_dir(),
2060            },
2061        )
2062        .unwrap_err();
2063        assert!(
2064            format!("{}", err).contains("recursive rm")
2065                || format!("{}", err).contains("rm command blocked"),
2066            "Should block 'r''m' -rf /"
2067        );
2068    }
2069
2070    #[test]
2071    fn blocks_rm_via_backslash_bypass() {
2072        // F-013: r\m -rf / should be blocked
2073        let err = Capability::execute(
2074            &ShellExec,
2075            &serde_json::json!({"cmd": "r\\m -rf /"}),
2076            &Context {
2077                dry_run: false,
2078                job_id: "test".into(),
2079                working_dir: std::env::temp_dir(),
2080            },
2081        )
2082        .unwrap_err();
2083        assert!(
2084            format!("{}", err).contains("recursive rm")
2085                || format!("{}", err).contains("rm command blocked"),
2086            "Should block r\\m -rf /"
2087        );
2088    }
2089
2090    #[test]
2091    fn blocks_dd_via_quote_bypass() {
2092        // F-013: d"d" if=/dev/zero should be blocked
2093        let err = Capability::execute(
2094            &ShellExec,
2095            &serde_json::json!({"cmd": "d\"d\" if=/dev/zero"}),
2096            &Context {
2097                dry_run: false,
2098                job_id: "test".into(),
2099                working_dir: std::env::temp_dir(),
2100            },
2101        )
2102        .unwrap_err();
2103        assert!(
2104            format!("{}", err).contains("dd"),
2105            "Should block d\"d\" (dd bypass)"
2106        );
2107    }
2108
2109    // ── F-014: Path Restriction Tests ────────────────────────────────────
2110
2111    #[test]
2112    fn blocks_cat_outside_allowed() {
2113        // cat /etc/passwd is outside allowed prefixes (/tmp, /var/tmp, /home)
2114        let err = Capability::execute(
2115            &ShellExec,
2116            &serde_json::json!({"cmd": "cat /etc/passwd"}),
2117            &Context {
2118                dry_run: false,
2119                job_id: "test".into(),
2120                working_dir: std::env::temp_dir(),
2121            },
2122        )
2123        .unwrap_err();
2124        assert!(
2125            format!("{}", err).contains("outside allowed directories"),
2126            "Should block cat /etc/passwd"
2127        );
2128    }
2129
2130    #[test]
2131    fn blocks_ls_tilde_ssh() {
2132        // ls ~/.ssh/ expands to /home/user/.ssh/ which is within /home but
2133        // .ssh is fine as it's a subdirectory — wait, ~/.ssh/ IS within /home.
2134        // Let me test with a path outside /home like ~/../root/.ssh
2135        // Actually ~/../root is traversal, which path validation catches.
2136        // Test: ls ~/../../etc/passwd contains ".." which is rejected.
2137        let err = Capability::execute(
2138            &ShellExec,
2139            &serde_json::json!({"cmd": "ls ~/../../etc/passwd"}),
2140            &Context {
2141                dry_run: false,
2142                job_id: "test".into(),
2143                working_dir: std::env::temp_dir(),
2144            },
2145        )
2146        .unwrap_err();
2147        // Should be blocked by ShellExec path check or by the ".." rejection
2148        let msg = format!("{}", err);
2149        assert!(
2150            msg.contains("outside allowed") || msg.contains("traversal"),
2151            "Should block ls ~/../../etc/passwd, got: {}",
2152            msg
2153        );
2154    }
2155
2156    #[test]
2157    fn blocks_path_to_root() {
2158        // Reading files in /root/ should be blocked (outside allowed prefixes)
2159        let err = Capability::execute(
2160            &ShellExec,
2161            &serde_json::json!({"cmd": "cat /root/.bashrc"}),
2162            &Context {
2163                dry_run: false,
2164                job_id: "test".into(),
2165                working_dir: std::env::temp_dir(),
2166            },
2167        )
2168        .unwrap_err();
2169        assert!(
2170            format!("{}", err).contains("outside allowed directories"),
2171            "Should block cat /root/.bashrc"
2172        );
2173    }
2174
2175    #[test]
2176    fn allows_cat_in_tmp() {
2177        // cat /tmp/somefile should be ALLOWED (within /tmp prefix)
2178        // Use echo to avoid the file-not-found issue
2179        let r = Capability::execute(
2180            &ShellExec,
2181            &serde_json::json!({"cmd": "echo test > /tmp/runtimo_path_test.txt && cat /tmp/runtimo_path_test.txt"}),
2182            &Context {
2183                dry_run: false,
2184                job_id: "test".into(),
2185                working_dir: std::env::temp_dir(),
2186            },
2187        );
2188        // Clean up
2189        let _ = std::fs::remove_file("/tmp/runtimo_path_test.txt");
2190        match r {
2191            Ok(o) => assert_eq!(o.status, "ok", "Should allow cat in /tmp"),
2192            Err(e) => {
2193                let msg = format!("{}", e);
2194                assert!(
2195                    !msg.contains("outside allowed"),
2196                    "Should NOT block /tmp path, got: {}",
2197                    msg
2198                );
2199            }
2200        }
2201    }
2202
2203    #[test]
2204    fn allows_cat_in_home() {
2205        // cat $HOME/somefile should be ALLOWED (within allowed prefix)
2206        let home = std::env::var("HOME").unwrap_or_else(|_| "/home/user".to_string());
2207        let allowed = crate::config::RuntimoConfig::get_allowed_prefixes();
2208        let is_allowed = allowed
2209            .iter()
2210            .any(|p| home.starts_with(p) || p.starts_with(&home));
2211        if !is_allowed {
2212            // Skip test when HOME is outside allowed prefixes (e.g., /root in CI/containers)
2213            eprintln!("SKIP: HOME ({}) is outside allowed prefixes; test requires HOME within allowed area", home);
2214            return;
2215        }
2216        let test_path = format!("{}/runtimo_home_path_test.txt", home);
2217        let cmd = format!("echo ok > {} && cat {}", test_path, test_path);
2218        let r = Capability::execute(
2219            &ShellExec,
2220            &serde_json::json!({"cmd": cmd}),
2221            &Context {
2222                dry_run: false,
2223                job_id: "test".into(),
2224                working_dir: std::env::temp_dir(),
2225            },
2226        );
2227        let _ = std::fs::remove_file(&test_path);
2228        match r {
2229            Ok(o) => assert_eq!(o.status, "ok", "Should allow cat in HOME"),
2230            Err(e) => {
2231                let msg = format!("{}", e);
2232                assert!(
2233                    !msg.contains("outside allowed"),
2234                    "Should NOT block HOME path, got: {}",
2235                    msg
2236                );
2237            }
2238        }
2239    }
2240
2241    // ── GAP-08: Variable-expanded path tests ───────────────────────────
2242
2243    #[test]
2244    fn blocks_var_expanded_path_outside_allowed() {
2245        // cat $HOME/.ssh/id_ed25519 should be blocked (outside /tmp, /var/tmp,
2246        // but typically inside /home. Actually this IS within /home prefix.
2247        // Let's use a path that's definitely outside: /etc/shadow via variable)
2248        // Test: cat $HOME/../../etc/shadow — contains ".." which is blocked
2249        let err = Capability::execute(
2250            &ShellExec,
2251            &serde_json::json!({"cmd": "cat $HOME/../../etc/shadow"}),
2252            &Context {
2253                dry_run: false,
2254                job_id: "test".into(),
2255                working_dir: std::env::temp_dir(),
2256            },
2257        )
2258        .unwrap_err();
2259        let msg = format!("{}", err);
2260        assert!(
2261            msg.contains("outside allowed") || msg.contains("traversal"),
2262            "Should block $HOME/../../etc/shadow, got: {}",
2263            msg
2264        );
2265    }
2266
2267    #[test]
2268    fn blocks_var_brace_expanded_path() {
2269        // cat ${HOME}/../../etc/shadow — brace syntax
2270        let err = Capability::execute(
2271            &ShellExec,
2272            &serde_json::json!({"cmd": "cat ${HOME}/../../etc/shadow"}),
2273            &Context {
2274                dry_run: false,
2275                job_id: "test".into(),
2276                working_dir: std::env::temp_dir(),
2277            },
2278        )
2279        .unwrap_err();
2280        let msg = format!("{}", err);
2281        assert!(
2282            msg.contains("outside allowed") || msg.contains("traversal"),
2283            "Should block brace-syntax var expansion"
2284        );
2285    }
2286
2287    #[test]
2288    fn test_expand_shell_vars_resolves_home() {
2289        // expand_shell_vars should resolve $HOME to the actual home directory
2290        let expanded = expand_shell_vars("$HOME/.ssh");
2291        let home = std::env::var("HOME").unwrap_or_default();
2292        assert!(
2293            expanded.starts_with(&home),
2294            "Should expand $HOME, got: {}",
2295            expanded
2296        );
2297        assert!(
2298            expanded.ends_with("/.ssh"),
2299            "Should keep suffix, got: {}",
2300            expanded
2301        );
2302    }
2303
2304    #[test]
2305    fn test_expand_shell_vars_brace_syntax() {
2306        let expanded = expand_shell_vars("${HOME}/.ssh");
2307        let home = std::env::var("HOME").unwrap_or_default();
2308        assert!(
2309            expanded.starts_with(&home),
2310            "Should expand brace-syntax var"
2311        );
2312    }
2313
2314    // ── GAP-10: Inline redirect path tests ────────────────────────────
2315
2316    #[test]
2317    fn blocks_redirect_to_outside_path() {
2318        // echo evil >/etc/cron.d/backdoor — redirect path should be checked
2319        let err = Capability::execute(
2320            &ShellExec,
2321            &serde_json::json!({"cmd": "echo evil >/etc/cron.d/backdoor"}),
2322            &Context {
2323                dry_run: false,
2324                job_id: "test".into(),
2325                working_dir: std::env::temp_dir(),
2326            },
2327        )
2328        .unwrap_err();
2329        assert!(
2330            format!("{}", err).contains("outside allowed directories"),
2331            "Should block redirect to /etc/cron.d/backdoor"
2332        );
2333    }
2334
2335    #[test]
2336    fn blocks_append_redirect_to_outside_path() {
2337        // echo evil >>/etc/hosts — append redirect should also be checked
2338        let err = Capability::execute(
2339            &ShellExec,
2340            &serde_json::json!({"cmd": "echo evil >>/etc/hosts"}),
2341            &Context {
2342                dry_run: false,
2343                job_id: "test".into(),
2344                working_dir: std::env::temp_dir(),
2345            },
2346        )
2347        .unwrap_err();
2348        assert!(
2349            format!("{}", err).contains("outside allowed directories"),
2350            "Should block append redirect to /etc/hosts"
2351        );
2352    }
2353
2354    #[test]
2355    fn blocks_stderr_redirect_outside() {
2356        // cmd 2>/etc/malicious — stderr redirect should be checked
2357        let err = Capability::execute(
2358            &ShellExec,
2359            &serde_json::json!({"cmd": "ls 2>/etc/malicious"}),
2360            &Context {
2361                dry_run: false,
2362                job_id: "test".into(),
2363                working_dir: std::env::temp_dir(),
2364            },
2365        )
2366        .unwrap_err();
2367        assert!(
2368            format!("{}", err).contains("outside allowed directories"),
2369            "Should block 2> redirect to /etc/malicious"
2370        );
2371    }
2372
2373    #[test]
2374    fn allows_redirect_to_allowed_path() {
2375        // echo hello >/tmp/test_redirect.txt should be allowed
2376        let r = Capability::execute(
2377            &ShellExec,
2378            &serde_json::json!({"cmd": "echo hello >/tmp/runtimo_redirect_test.txt"}),
2379            &Context {
2380                dry_run: false,
2381                job_id: "test".into(),
2382                working_dir: std::env::temp_dir(),
2383            },
2384        );
2385        let _ = std::fs::remove_file("/tmp/runtimo_redirect_test.txt");
2386        match r {
2387            Ok(o) => assert_eq!(o.status, "ok"),
2388            Err(e) => {
2389                assert!(
2390                    !format!("{}", e).contains("outside allowed"),
2391                    "Should NOT block redirect to /tmp, got: {}",
2392                    e
2393                );
2394            }
2395        }
2396    }
2397
2398    // ── GAP-13: Relative path traversal tests ──────────────────────────
2399
2400    #[test]
2401    fn blocks_relative_parent_traversal() {
2402        // cat ../../etc/passwd — relative path escaping from /tmp
2403        let err = Capability::execute(
2404            &ShellExec,
2405            &serde_json::json!({"cmd": "cat ../../etc/passwd"}),
2406            &Context {
2407                dry_run: false,
2408                job_id: "test".into(),
2409                working_dir: std::env::temp_dir(),
2410            },
2411        )
2412        .unwrap_err();
2413        assert!(
2414            format!("{}", err).contains("outside allowed directories"),
2415            "Should block relative path traversal to /etc/passwd"
2416        );
2417    }
2418
2419    #[test]
2420    fn blocks_deep_relative_traversal() {
2421        // cat ./../../../etc/shadow — deeper traversal
2422        let err = Capability::execute(
2423            &ShellExec,
2424            &serde_json::json!({"cmd": "cat ./../../../etc/shadow"}),
2425            &Context {
2426                dry_run: false,
2427                job_id: "test".into(),
2428                working_dir: std::env::temp_dir(),
2429            },
2430        )
2431        .unwrap_err();
2432        assert!(
2433            format!("{}", err).contains("outside allowed directories"),
2434            "Should block deep relative traversal"
2435        );
2436    }
2437
2438    #[test]
2439    fn allows_relative_within_allowed() {
2440        // Relative paths resolve against CWD. During testing, CWD is
2441        // the project root (outside /tmp), so relative paths won't be
2442        // within allowed prefixes. Use an absolute /tmp path instead
2443        // for the "allowed" case.
2444        let test_file = "/tmp/runtimo_relative_allowed_test.txt";
2445        let r = Capability::execute(
2446            &ShellExec,
2447            &serde_json::json!({"cmd": format!("echo ok > {}", test_file)}),
2448            &Context {
2449                dry_run: false,
2450                job_id: "test".into(),
2451                working_dir: std::env::temp_dir(),
2452            },
2453        );
2454        let _ = std::fs::remove_file(test_file);
2455        match r {
2456            Ok(o) => assert_eq!(o.status, "ok", "Should allow path within /tmp"),
2457            Err(e) => {
2458                let msg = format!("{}", e);
2459                assert!(
2460                    !msg.contains("outside allowed"),
2461                    "Should NOT block /tmp path, got: {}",
2462                    msg
2463                );
2464            }
2465        }
2466    }
2467
2468    // ── F-015: Env Protection Tests ─────────────────────────────────────
2469
2470    #[test]
2471    fn blocks_env_command() {
2472        let err = Capability::execute(
2473            &ShellExec,
2474            &serde_json::json!({"cmd": "env"}),
2475            &Context {
2476                dry_run: false,
2477                job_id: "test".into(),
2478                working_dir: std::env::temp_dir(),
2479            },
2480        )
2481        .unwrap_err();
2482        assert!(
2483            format!("{}", err).contains("environment variable dumping"),
2484            "Should block `env` command"
2485        );
2486    }
2487
2488    #[test]
2489    fn blocks_printenv_command() {
2490        let err = Capability::execute(
2491            &ShellExec,
2492            &serde_json::json!({"cmd": "printenv"}),
2493            &Context {
2494                dry_run: false,
2495                job_id: "test".into(),
2496                working_dir: std::env::temp_dir(),
2497            },
2498        )
2499        .unwrap_err();
2500        assert!(
2501            format!("{}", err).contains("environment variable dumping"),
2502            "Should block `printenv` command"
2503        );
2504    }
2505
2506    #[test]
2507    fn blocks_set_command() {
2508        let err = Capability::execute(
2509            &ShellExec,
2510            &serde_json::json!({"cmd": "set"}),
2511            &Context {
2512                dry_run: false,
2513                job_id: "test".into(),
2514                working_dir: std::env::temp_dir(),
2515            },
2516        )
2517        .unwrap_err();
2518        assert!(
2519            format!("{}", err).contains("environment variable dumping"),
2520            "Should block `set` command"
2521        );
2522    }
2523
2524    #[test]
2525    fn blocks_export_command() {
2526        let err = Capability::execute(
2527            &ShellExec,
2528            &serde_json::json!({"cmd": "export"}),
2529            &Context {
2530                dry_run: false,
2531                job_id: "test".into(),
2532                working_dir: std::env::temp_dir(),
2533            },
2534        )
2535        .unwrap_err();
2536        assert!(
2537            format!("{}", err).contains("environment variable dumping"),
2538            "Should block `export` command"
2539        );
2540    }
2541
2542    // ── GAP-12: export with assignment still blocked ──────────────────
2543
2544    #[test]
2545    fn blocks_export_with_assignment() {
2546        // GAP-12: export FOO=bar should also be blocked — the export
2547        // keyword itself triggers the env-dumping blocklist.
2548        let err = Capability::execute(
2549            &ShellExec,
2550            &serde_json::json!({"cmd": "export FOO=bar"}),
2551            &Context {
2552                dry_run: false,
2553                job_id: "test".into(),
2554                working_dir: std::env::temp_dir(),
2555            },
2556        )
2557        .unwrap_err();
2558        assert!(
2559            format!("{}", err).contains("environment variable dumping"),
2560            "Should block `export FOO=bar` (export with assignment)"
2561        );
2562    }
2563
2564    #[test]
2565    fn blocks_declare_p_command() {
2566        let err = Capability::execute(
2567            &ShellExec,
2568            &serde_json::json!({"cmd": "declare -p"}),
2569            &Context {
2570                dry_run: false,
2571                job_id: "test".into(),
2572                working_dir: std::env::temp_dir(),
2573            },
2574        )
2575        .unwrap_err();
2576        assert!(
2577            format!("{}", err).contains("environment variable dumping"),
2578            "Should block `declare -p` command"
2579        );
2580    }
2581
2582    #[test]
2583    fn blocks_env_via_quote_bypass() {
2584        // F-013 + F-015: e"n"v should also be blocked
2585        let err = Capability::execute(
2586            &ShellExec,
2587            &serde_json::json!({"cmd": "e\"n\"v"}),
2588            &Context {
2589                dry_run: false,
2590                job_id: "test".into(),
2591                working_dir: std::env::temp_dir(),
2592            },
2593        )
2594        .unwrap_err();
2595        assert!(
2596            format!("{}", err).contains("environment variable dumping"),
2597            "Should block e\"n\"v (quoted env bypass)"
2598        );
2599    }
2600
2601    #[test]
2602    fn allows_harmless_command_with_env_check() {
2603        // Normal commands should still work even with env sanitization
2604        let r = Capability::execute(
2605            &ShellExec,
2606            &serde_json::json!({"cmd": "echo hello"}),
2607            &Context {
2608                dry_run: false,
2609                job_id: "test".into(),
2610                working_dir: std::env::temp_dir(),
2611            },
2612        )
2613        .unwrap();
2614        assert_eq!(r.status, "ok");
2615        assert!(r.data.as_ref().unwrap()["stdout"]
2616            .as_str()
2617            .unwrap()
2618            .contains("hello"));
2619    }
2620
2621    #[test]
2622    fn is_sensitive_env_var_detects_aws() {
2623        assert!(is_sensitive_env_var("AWS_ACCESS_KEY_ID"));
2624        assert!(is_sensitive_env_var("AWS_SECRET_ACCESS_KEY"));
2625        assert!(is_sensitive_env_var("aws_session_token")); // case-insensitive
2626    }
2627
2628    #[test]
2629    fn is_sensitive_env_var_detects_suffixes() {
2630        assert!(is_sensitive_env_var("MYAPP_API_KEY"));
2631        assert!(is_sensitive_env_var("GITHUB_TOKEN"));
2632        assert!(is_sensitive_env_var("DB_PASSWORD"));
2633        assert!(is_sensitive_env_var("STRIPE_SECRET_KEY"));
2634    }
2635
2636    #[test]
2637    fn is_sensitive_env_var_allows_safe() {
2638        assert!(!is_sensitive_env_var("HOME"));
2639        assert!(!is_sensitive_env_var("USER"));
2640        assert!(!is_sensitive_env_var("PATH"));
2641        assert!(!is_sensitive_env_var("TERM"));
2642        assert!(!is_sensitive_env_var("LANG"));
2643        assert!(!is_sensitive_env_var("RUNTIMO_ENABLE_NETWORK"));
2644    }
2645
2646    // ── GAP-07: Non-secret vars matching suffix patterns ──────────────
2647
2648    #[test]
2649    fn is_sensitive_env_var_allows_known_non_secret_suffix() {
2650        // GAP-07: FOREIGN_KEY matches *_KEY suffix but is a database term
2651        assert!(!is_sensitive_env_var("FOREIGN_KEY"));
2652        assert!(!is_sensitive_env_var("PRIMARY_KEY"));
2653        assert!(!is_sensitive_env_var("PUBLIC_KEY"));
2654        assert!(!is_sensitive_env_var("BASE_URL"));
2655    }
2656
2657    // ── GAP-15: Dynamic linker env var tests ───────────────────────────
2658
2659    #[test]
2660    fn is_sensitive_env_var_detects_ld_preload() {
2661        // LD_PRELOAD can inject arbitrary shared libraries
2662        assert!(is_sensitive_env_var("LD_PRELOAD"));
2663        assert!(is_sensitive_env_var("LD_LIBRARY_PATH"));
2664        assert!(is_sensitive_env_var("LD_DEBUG"));
2665        assert!(is_sensitive_env_var("LD_BIND_NOW"));
2666    }
2667
2668    #[test]
2669    fn is_sensitive_env_var_detects_dyld() {
2670        // macOS dynamic linker injection
2671        assert!(is_sensitive_env_var("DYLD_INSERT_LIBRARIES"));
2672        assert!(is_sensitive_env_var("DYLD_LIBRARY_PATH"));
2673    }
2674
2675    #[test]
2676    fn sanitized_env_strips_secrets() {
2677        // Set a test secret and verify it's stripped
2678        std::env::set_var("RUNTIMO_TEST_SECRET_KEY", "test-value");
2679        let env = sanitized_env();
2680        std::env::remove_var("RUNTIMO_TEST_SECRET_KEY");
2681
2682        assert!(
2683            !env.iter()
2684                .map(|(k, _)| k.as_str())
2685                .any(|x| x == "RUNTIMO_TEST_SECRET_KEY"),
2686            "RUNTIMO_TEST_SECRET_KEY should be stripped from env"
2687        );
2688    }
2689
2690    #[test]
2691    fn sanitized_env_preserves_safe() {
2692        let env = sanitized_env();
2693        let keys: Vec<&str> = env.iter().map(|(k, _)| k.as_str()).collect();
2694        assert!(keys.contains(&"HOME"), "HOME should be preserved");
2695        assert!(keys.contains(&"USER"), "USER should be preserved");
2696    }
2697}