use crate::capability::{CapabilityError, Context, Output, TypedCapability};
use crate::config::RuntimoConfig;
use crate::validation::path::{validate_path, PathContext};
use crate::{Error, Result};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::fs;
use std::io::{Read, Write};
use std::os::unix::process::CommandExt;
use std::process::{Child, Command, ExitStatus};
use std::thread;
use std::time::{Duration, Instant};
type WaitResult = Result<(ExitStatus, Vec<u8>, Vec<u8>, Vec<u32>)>;
const DEFAULT_TIMEOUT_SECS: u64 = 30;
const MAX_OUTPUT_BYTES: usize = 10 * 1024 * 1024;
const MAX_STDIN_BYTES: usize = 1024 * 1024;
const SENSITIVE_ENV_PREFIXES: &[&str] = &[
"RUSTIMO_",
"AWS_",
"GITHUB_",
"GITLAB_",
"SSH_",
"GPG_",
"DOCKER_",
"VAULT_",
"NOMAD_",
"CONSUL_",
"HEROKU_",
"AZURE_",
"GCLOUD_",
"GOOGLE_CLOUD",
"GOOGLE_APPLICATION",
"SENTRY_DSN",
"DATADOG_",
"NEW_RELIC_",
"STRIPE_",
"TWILIO_",
"SENDGRID_",
"MAILGUN_",
"LDAP_",
"KRB5_",
"CUDA_", "LD_",
"DYLD_",
];
const SENSITIVE_ENV_SUFFIXES: &[&str] = &[
"_KEY",
"_TOKEN",
"_SECRET",
"_PASSWORD",
"_SECRETS",
"_CREDENTIAL",
"_CREDENTIALS",
"_CERT",
"_CERTIFICATE",
"_PRIVATE_KEY",
"_ACCESS_KEY",
"_SECRET_KEY",
"_SIGNING_KEY",
"_ENCRYPTION_KEY",
"_DECRYPTION_KEY",
"_API_KEY",
"_AUTH_TOKEN",
"_DSN",
"_URL",
];
const SAFE_ENV_VARS: &[&str] = &[
"HOME",
"USER",
"LOGNAME",
"PATH",
"TERM",
"LANG",
"LC_ALL",
"LC_CTYPE",
"TZ",
"PWD",
"OLDPWD",
"SHELL",
"EDITOR",
"VISUAL",
"DISPLAY",
"XAUTHORITY",
"WAYLAND_DISPLAY",
"DBUS_SESSION_BUS_ADDRESS",
"XDG_RUNTIME_DIR",
"XDG_SESSION_TYPE",
"XDG_CURRENT_DESKTOP",
"XDG_CONFIG_HOME",
"XDG_DATA_HOME",
"XDG_CACHE_HOME",
"COLORTERM",
"NO_COLOR",
"CLICOLOR",
"HOSTNAME",
"HOST",
"MACHTYPE",
"OSTYPE",
"SHLVL",
"LINENO",
"PPID",
"EUID",
"UID",
"RUNTIMO_ENABLE_NETWORK",
"RUNTIMO_ENABLE_INTERPRETERS",
"FOREIGN_KEY",
"PRIMARY_KEY",
"PUBLIC_KEY",
"BROWSER_URL",
"BASE_URL",
];
#[derive(Debug, Clone, Serialize, Deserialize)]
#[allow(clippy::exhaustive_structs)] pub struct ShellExecArgs {
#[serde(alias = "command")]
pub cmd: String,
pub timeout_secs: Option<u64>,
pub cwd: Option<String>,
pub stdin: Option<String>,
}
fn command_matches(cmd_lower: &str, names: &[&str]) -> bool {
let first_token = cmd_lower.split_whitespace().next().unwrap_or("");
for part in cmd_lower.split(['|', '&', ';']) {
let t = part.trim();
if names
.iter()
.any(|n| t == *n || t.starts_with(&format!("{} ", n)))
{
return true;
}
}
names.contains(&first_token)
}
#[must_use]
pub fn is_dangerous_command(cmd: &str) -> Option<&'static str> {
let cmd_lower = cmd.to_lowercase();
let detok_lower = detokenize_command(&cmd_lower);
if is_env_dumping_command(cmd) {
return Some("environment variable dumping command is blocked");
}
{
let has_func_def = cmd_lower.contains(":(){")
|| cmd_lower.contains(":(){ ")
|| cmd_lower.contains(":() {")
|| detok_lower.contains(":(){")
|| detok_lower.contains(":(){ ")
|| detok_lower.contains(":() {");
let has_self_pipe = cmd_lower.contains(":|:&")
|| cmd_lower.contains(":|: &")
|| detok_lower.contains(":|:&")
|| detok_lower.contains(":|: &");
if has_func_def && has_self_pipe {
return Some("fork bomb pattern blocked");
}
}
if cmd_lower.contains("<<") || detok_lower.contains("<<") {
return Some("heredoc/herestring (<<) is blocked — use inline commands");
}
if cmd_lower.contains("<(")
|| cmd_lower.contains(">(")
|| detok_lower.contains("<(")
|| detok_lower.contains(">(")
{
return Some("process substitution (<( ) or >( )) is blocked");
}
if cmd.contains("$(")
|| cmd.contains('`')
|| detok_lower.contains("$(")
|| detok_lower.contains('`')
{
return Some("command substitution ($( ) or backtick) is blocked");
}
if command_matches(&cmd_lower, &["rm"]) || command_matches(&detok_lower, &["rm"]) {
return Some("rm command blocked — use FileWrite/Undo capability");
}
let rm_no_preserve = "rm".to_string() + " --no-preserve-root";
if (cmd_lower.contains("rm") && cmd_lower.contains("--no-preserve-root"))
|| (detok_lower.contains("rm") && detok_lower.contains("--no-preserve-root"))
|| detok_lower.contains(&rm_no_preserve)
{
return Some("rm --no-preserve-root is blocked");
}
let rm_recursive_check = |s: &str| -> bool {
s.contains("rm")
&& (s.contains("-rf")
|| s.contains("-fr")
|| s.contains("--recursive")
|| s.contains(" -r ")
|| s.contains(" -f "))
};
if rm_recursive_check(&cmd_lower) || rm_recursive_check(&detok_lower) {
return Some("recursive rm is blocked");
}
let mkfs_check = |s: &str| -> bool { s.contains("mkfs") || s.contains("mkswap") };
if mkfs_check(&cmd_lower) || mkfs_check(&detok_lower) {
return Some("filesystem creation commands are blocked");
}
let fdisk_check = |s: &str| -> bool { s.contains("fdisk") || s.contains("parted") };
if fdisk_check(&cmd_lower) || fdisk_check(&detok_lower) {
return Some("disk partitioning commands are blocked");
}
let dd_check = |s: &str| -> bool {
s.contains(" dd ")
|| s.starts_with("dd ")
|| s.ends_with(" dd")
|| s.contains(" dd\t")
|| s.starts_with("dd\t")
};
if dd_check(&cmd_lower) || dd_check(&detok_lower) {
return Some("dd (disk destroyer) is blocked");
}
if command_matches(&cmd_lower, &["shred"]) || command_matches(&detok_lower, &["shred"]) {
return Some("shred command blocked — use FileWrite/Undo capability");
}
let power_check = |s: &str| -> bool {
s.contains("shutdown") || s.contains("reboot") || s.contains("poweroff")
};
if power_check(&cmd_lower) || power_check(&detok_lower) {
return Some("system power commands are blocked");
}
if command_matches(&cmd_lower, &["chown", "chgrp"])
|| command_matches(&detok_lower, &["chown", "chgrp"])
{
return Some("ownership change commands are blocked");
}
if command_matches(&cmd_lower, &["mount", "umount"])
|| command_matches(&detok_lower, &["mount", "umount"])
{
return Some("mount/unmount commands are blocked");
}
if command_matches(&cmd_lower, &["iptables", "nft"])
|| command_matches(&detok_lower, &["iptables", "nft"])
{
return Some("firewall manipulation commands are blocked");
}
if command_matches(&cmd_lower, &["kill", "killall", "pkill"])
|| command_matches(&detok_lower, &["kill", "killall", "pkill"])
{
return Some("kill command blocked — use Kill capability");
}
let rm_system_check = |s: &str| -> bool {
s.contains("rm")
&& (s.contains("-rf")
|| s.contains("-fr")
|| s.contains("--recursive")
|| s.contains(" -r ")
|| s.contains(" -f "))
&& (s.contains(" / ")
|| s.contains("/*")
|| s.contains("/dev")
|| s.contains("/boot")
|| s.contains("/home")
|| s.contains("/etc")
|| s.contains("/usr")
|| s.contains("/var")
|| s.contains("/lib")
|| s.contains("/opt")
|| s.contains("/bin")
|| s.contains("/sbin"))
};
if rm_system_check(&cmd_lower) || rm_system_check(&detok_lower) {
return Some("rm -rf / --recursive on system directories is blocked");
}
let rm_tilde_check = |s: &str| -> bool {
s.contains("rm")
&& (s.contains("-rf")
|| s.contains("-fr")
|| s.contains("--recursive")
|| s.contains(" -r ")
|| s.contains(" -f "))
&& s.contains('~')
};
if rm_tilde_check(&cmd_lower) || rm_tilde_check(&detok_lower) {
return Some("rm with shell expansions is blocked — use explicit paths");
}
if command_matches(&cmd_lower, &["chmod"]) || command_matches(&detok_lower, &["chmod"]) {
return Some("chmod command blocked — use FileWrite/Undo capability");
}
None
}
#[must_use]
pub fn is_network_command(cmd: &str) -> bool {
let cmd_lower = cmd.to_lowercase();
command_matches(
&cmd_lower,
&[
"curl", "wget", "nc", "ncat", "netcat", "socat", "ssh", "scp", "telnet",
],
)
}
#[must_use]
pub fn network_enabled() -> bool {
std::env::var("RUNTIMO_ENABLE_NETWORK").as_deref() == Ok("1")
}
#[must_use]
pub fn is_interpreter_command(cmd: &str) -> bool {
let cmd_lower = cmd.to_lowercase();
command_matches(
&cmd_lower,
&[
"python", "python3", "python2", "perl", "ruby", "node", "lua", "php", "tclsh", "wish",
"racket", "guile", "ghci", "runghc", "scala", "gawk", "nawk",
],
)
}
#[must_use]
pub fn interpreters_enabled() -> bool {
std::env::var("RUNTIMO_ENABLE_INTERPRETERS").as_deref() == Ok("1")
}
#[must_use]
pub fn detokenize_command(cmd: &str) -> String {
const MAX_PASSES: usize = 16;
let mut current = cmd.to_string();
let mut previous;
let mut passes: usize = 0;
loop {
previous = current.clone();
current = detokenize_single_pass(&previous);
passes = passes.saturating_add(1);
if current == previous || passes >= MAX_PASSES {
break;
}
}
current
}
#[must_use]
fn detokenize_single_pass(cmd: &str) -> String {
let mut result = String::with_capacity(cmd.len());
let mut chars = cmd.chars().peekable();
while let Some(&c) = chars.peek() {
match c {
'\\' => {
chars.next(); if let Some(next) = chars.next() {
if next != '\n' {
result.push(next);
}
}
}
'$' => {
chars.next(); if chars.peek() == Some(&'\'') {
chars.next(); while let Some(ch) = chars.next() {
if ch == '\'' {
break; }
if ch == '\\' {
match chars.next() {
Some('\\') => result.push('\\'),
Some('\'') => result.push('\''),
Some('"') => result.push('"'),
Some('?') => result.push('?'),
Some('a') => result.push('a'),
Some('b') => result.push('b'),
Some('f') => result.push('f'),
Some('n' | 'r' | 't' | 'v') => {
result.push(' ');
}
Some('e' | 'E') => result.push('e'),
Some('x') => {
let mut hex = String::new();
for _ in 0..2 {
if let Some(&h) = chars.peek() {
if h.is_ascii_hexdigit() {
hex.push(h);
chars.next();
} else {
break;
}
}
}
if let Ok(byte) = u8::from_str_radix(&hex, 16) {
if let Some(c) = char::from_u32(u32::from(byte)) {
result.push(c);
}
}
}
Some('u') => {
let mut hex = String::new();
for _ in 0..4 {
if let Some(&h) = chars.peek() {
if h.is_ascii_hexdigit() {
hex.push(h);
chars.next();
} else {
break;
}
}
}
if let Ok(cp) = u32::from_str_radix(&hex, 16) {
if let Some(c) = char::from_u32(cp) {
result.push(c);
}
}
}
Some('U') => {
let mut hex = String::new();
for _ in 0..8 {
if let Some(&h) = chars.peek() {
if h.is_ascii_hexdigit() {
hex.push(h);
chars.next();
} else {
break;
}
}
}
if let Ok(cp) = u32::from_str_radix(&hex, 16) {
if let Some(c) = char::from_u32(cp) {
result.push(c);
}
}
}
Some(d) if ('0'..='7').contains(&d) => {
let mut octal = String::from(d);
for _ in 0..2 {
if let Some(&od) = chars.peek() {
if ('0'..='7').contains(&od) {
octal.push(od);
chars.next();
} else {
break;
}
}
}
if let Ok(byte) = u8::from_str_radix(&octal, 8) {
if let Some(c) = char::from_u32(u32::from(byte)) {
result.push(c);
}
}
}
Some(ctrl) => result.push(ctrl),
None => {}
}
} else {
result.push(ch);
}
}
} else {
result.push('$');
}
}
'\'' => {
chars.next(); for ch in chars.by_ref() {
if ch == '\'' {
break; }
result.push(ch);
}
}
'"' => {
chars.next(); while let Some(ch) = chars.next() {
if ch == '"' {
break; }
if ch == '\\' {
if let Some(&next_ch) = chars.peek() {
match next_ch {
'"' | '$' | '`' | '\\' | '\n' => {
chars.next(); result.push(next_ch);
continue;
}
_ => {
result.push('\\');
continue;
}
}
}
result.push('\\');
break;
}
result.push(ch);
}
}
_ => {
result.push(c);
chars.next(); }
}
}
result
}
#[must_use]
fn is_sensitive_env_var(key: &str) -> bool {
if SAFE_ENV_VARS.contains(&key) {
return false;
}
let key_upper = key.to_uppercase();
if SENSITIVE_ENV_PREFIXES
.iter()
.any(|prefix| key_upper.starts_with(prefix))
{
return true;
}
SENSITIVE_ENV_SUFFIXES
.iter()
.any(|suffix| key_upper.ends_with(suffix))
}
#[must_use]
fn sanitized_env() -> Vec<(String, String)> {
std::env::vars()
.filter(|(key, _)| !is_sensitive_env_var(key))
.collect()
}
#[must_use]
pub fn is_env_dumping_command(cmd: &str) -> bool {
let cmd_lower = cmd.to_lowercase().trim().to_string();
let detok_lower = detokenize_command(&cmd_lower);
let env_dumpers: &[&str] = &[
"env", "printenv", "set", "export", "declare", "typeset", "compgen",
];
for dumper in env_dumpers {
if command_matches(&cmd_lower, &[dumper]) {
return true;
}
if command_matches(&detok_lower, &[dumper]) {
return true;
}
}
false
}
#[must_use]
fn is_path_within_allowed(path_str: &str, allowed: &[String]) -> bool {
allowed
.iter()
.filter(|prefix| !prefix.is_empty())
.any(|prefix| path_str == prefix || path_str.starts_with(&format!("{}/", prefix)))
}
#[must_use]
fn expand_shell_vars(token: &str) -> String {
let mut result = String::with_capacity(token.len());
let mut chars = token.chars().peekable();
let mut expanded_any = false;
while let Some(&c) = chars.peek() {
if c == '$' {
chars.next(); if chars.peek() == Some(&'{') {
chars.next(); let mut var_name = String::new();
while let Some(&ch) = chars.peek() {
if ch == '}' {
chars.next(); break;
}
var_name.push(ch);
chars.next();
}
if let Ok(value) = std::env::var(&var_name) {
result.push_str(&value);
expanded_any = true;
} else {
result.push('$');
result.push('{');
result.push_str(&var_name);
result.push('}');
}
} else {
let mut var_name = String::new();
while let Some(&ch) = chars.peek() {
if ch.is_alphanumeric() || ch == '_' {
var_name.push(ch);
chars.next();
} else {
break;
}
}
if var_name.is_empty() {
result.push('$');
} else if let Ok(value) = std::env::var(&var_name) {
result.push_str(&value);
expanded_any = true;
} else {
result.push('$');
result.push_str(&var_name);
}
}
} else {
result.push(c);
chars.next();
}
}
if expanded_any {
result
} else {
token.to_string()
}
}
#[must_use]
fn check_command_paths(cmd: &str) -> Option<String> {
let allowed = RuntimoConfig::get_allowed_prefixes();
let detok = detokenize_command(cmd);
for token in detok.split_whitespace() {
let path = token.trim_matches(|c: char| c == '"' || c == '\'' || c == '`' || c == ',');
if path.is_empty() || path.len() < 2 {
continue;
}
if path.starts_with('-') {
continue;
}
let path_without_redirect = path
.trim_start_matches("&>>")
.trim_start_matches("2>>")
.trim_start_matches("1>>")
.trim_start_matches("&>")
.trim_start_matches("2>")
.trim_start_matches("1>")
.trim_start_matches(">>")
.trim_start_matches('>')
.trim_start_matches('<');
let path = if path_without_redirect != path
&& (path_without_redirect.starts_with('/') || path_without_redirect.starts_with("~/"))
{
path_without_redirect
} else {
path
};
if path.contains('=') && !path.starts_with('/') && !path.starts_with("~/") {
continue;
}
let resolved = if path.contains('$') {
let expanded = expand_shell_vars(path);
if expanded == path {
continue;
}
if expanded.starts_with('/') {
let clean = expanded.trim_end_matches(|c: char| {
c == ';' || c == '|' || c == '&' || c == '>' || c == '<'
});
clean.to_string()
} else {
continue;
}
} else if path.starts_with("~/") {
match std::env::var("HOME") {
Ok(home) => {
let mut home_path = home.trim_end_matches('/').to_string();
home_path.push_str(&path[1..]); home_path
}
Err(_) => continue, }
} else if path.starts_with('/') {
let clean_end = path.trim_end_matches(|c: char| {
c == ';' || c == '|' || c == '&' || c == '>' || c == '<'
});
clean_end.to_string()
} else if path.starts_with('.') {
let Ok(cwd) = std::env::current_dir() else {
continue;
};
let joined = cwd.join(path);
let mut components: Vec<&str> = Vec::new();
for component in joined.components() {
match component {
std::path::Component::ParentDir => {
components.pop(); }
std::path::Component::Normal(os_str) => {
if let Some(s) = os_str.to_str() {
components.push(s);
}
}
std::path::Component::RootDir => {
components.clear();
}
_ => {}
}
}
let normalized = format!("/{}", components.join("/"));
if normalized.contains("/../") || normalized.contains("/..") || normalized == "/.." {
return Some(format!(
"ShellExec blocked: path traversal not allowed: {}",
path
));
}
normalized
} else {
continue;
};
if resolved == "/" {
continue;
}
if resolved.contains("/../") || resolved.contains("/..") || resolved == ".." {
return Some(format!(
"ShellExec blocked: path traversal not allowed: {}",
path
));
}
if resolved.starts_with("/dev/") {
continue;
}
if resolved.starts_with("/proc/") || resolved.starts_with("/sys/") {
continue;
}
if !is_path_within_allowed(&resolved, &allowed) {
let display_path = if path.starts_with("~/") {
path.to_string()
} else {
resolved
};
return Some(format!(
"ShellExec blocked: path is outside allowed directories: {}",
display_path
));
}
}
None
}
#[allow(clippy::arithmetic_side_effects)] fn wait_with_timeout(child: &mut Child, pgid: u32, timeout_secs: u64) -> WaitResult {
let start = Instant::now();
let timeout = Duration::from_secs(timeout_secs);
let child_pid = child.id();
let stdout_thread = child.stdout.take().map(|stdout| {
thread::spawn(move || {
let mut data = Vec::new();
let _ = stdout.take(MAX_OUTPUT_BYTES as u64).read_to_end(&mut data);
data
})
});
let stderr_thread = child.stderr.take().map(|stderr| {
thread::spawn(move || {
let mut data = Vec::new();
let _ = stderr.take(MAX_OUTPUT_BYTES as u64).read_to_end(&mut data);
data
})
});
let mut last_descendants: Vec<u32>;
loop {
if start.elapsed() > timeout {
#[allow(clippy::cast_possible_wrap)]
unsafe {
let _ = libc::kill(-(pgid as libc::pid_t), libc::SIGKILL);
}
let killed_descendants = get_all_descendants(child_pid);
let _ = child.wait();
let _ = stdout_thread.map(|h| h.join().unwrap_or_default());
let _ = stderr_thread.map(|h| h.join().unwrap_or_default());
return Err(Error::ExecutionFailed(format!(
"command timed out after {}s (killed {} descendants)",
timeout_secs,
killed_descendants.len()
)));
}
last_descendants = get_all_descendants(child_pid);
match child.try_wait() {
Ok(Some(status)) => {
let stdout_data = stdout_thread
.map(|h| h.join().unwrap_or_default())
.unwrap_or_default();
let stderr_data = stderr_thread
.map(|h| h.join().unwrap_or_default())
.unwrap_or_default();
return Ok((status, stdout_data, stderr_data, last_descendants));
}
Ok(None) => std::thread::sleep(Duration::from_millis(50)),
Err(e) => return Err(Error::ExecutionFailed(format!("error waiting: {}", e))),
}
}
}
fn get_direct_children(pid: u32) -> Vec<u32> {
let children_path = format!("/proc/{}/children", pid);
if let Ok(content) = fs::read_to_string(&children_path) {
content
.split_whitespace()
.filter_map(|s| s.parse::<u32>().ok())
.collect()
} else {
Vec::new()
}
}
fn get_all_descendants(pid: u32) -> Vec<u32> {
let mut descendants = Vec::new();
let mut stack = vec![pid];
let mut visited = std::collections::HashSet::new();
while let Some(current) = stack.pop() {
if visited.contains(¤t) {
continue;
}
visited.insert(current);
let children = get_direct_children(current);
if children.is_empty() {
if let Ok(output) = std::process::Command::new("pgrep")
.arg("-P")
.arg(current.to_string())
.output()
{
if output.status.success() {
let pgrep_lines = String::from_utf8_lossy(&output.stdout).to_string();
let pgrep_children = pgrep_lines
.lines()
.filter_map(|s| s.trim().parse::<u32>().ok());
for child in pgrep_children {
if !visited.contains(&child) {
descendants.push(child);
stack.push(child);
}
}
continue;
}
}
}
for child in children {
if !visited.contains(&child) {
descendants.push(child);
stack.push(child);
}
}
}
descendants
}
#[allow(clippy::exhaustive_structs)]
pub struct ShellExec;
impl TypedCapability for ShellExec {
type Args = ShellExecArgs;
fn name(&self) -> &'static str {
"ShellExec"
}
fn description(&self) -> &'static str {
"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."
}
fn schema(&self) -> Value {
serde_json::json!({
"type": "object",
"properties": {
"cmd": { "type": "string", "description": "Command to execute via sh -c" },
"timeout_secs": { "type": "integer", "minimum": 1, "maximum": 300 },
"cwd": { "type": "string" },
"stdin": { "type": "string" }
},
"required": ["cmd"]
})
}
fn execute(
&self,
args: ShellExecArgs,
ctx: &Context,
) -> std::result::Result<Output, CapabilityError> {
let timeout = args.timeout_secs.unwrap_or(DEFAULT_TIMEOUT_SECS);
if let Some(reason) = is_dangerous_command(&args.cmd) {
return Err(CapabilityError::PermissionDenied(format!(
"dangerous command blocked: {}",
reason
)));
}
if !network_enabled() && is_network_command(&args.cmd) {
return Err(CapabilityError::PermissionDenied(
"network commands blocked — set RUNTIMO_ENABLE_NETWORK=1 to enable".into(),
));
}
if !interpreters_enabled() && is_interpreter_command(&args.cmd) {
return Err(CapabilityError::PermissionDenied(
"interpreter commands blocked — set RUNTIMO_ENABLE_INTERPRETERS=1 to enable".into(),
));
}
if let Some(reason) = check_command_paths(&args.cmd) {
return Err(CapabilityError::PermissionDenied(reason));
}
if ctx.dry_run {
let mut out = Output::ok("DRY RUN".into());
out.data = Some(serde_json::json!({ "cmd": &args.cmd, "dry_run": true }));
return Ok(out);
}
let mut cmd = Command::new("sh");
cmd.env("PATH", "/usr/local/bin:/usr/bin:/bin");
let safe_env = sanitized_env();
for (key, value) in &safe_env {
if key == "PATH" {
continue;
}
cmd.env(key, value);
}
cmd.arg("-c").arg(&args.cmd);
if let Some(cwd) = &args.cwd {
let path_ctx = PathContext {
require_exists: true,
require_file: false,
..Default::default()
};
let cwd_path = validate_path(cwd, &path_ctx)
.map_err(|e| CapabilityError::PermissionDenied(format!("invalid cwd: {}", e)))?;
cmd.current_dir(cwd_path);
}
let mut child = cmd
.process_group(0)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.stdin(if args.stdin.is_some() {
std::process::Stdio::piped()
} else {
std::process::Stdio::null()
})
.spawn()
.map_err(|e| {
CapabilityError::Io(std::io::Error::other(format!("failed to spawn: {}", e)))
})?;
let child_pid = child.id();
let pgid = child_pid;
if let Some(ref stdin_content) = args.stdin {
if stdin_content.len() > MAX_STDIN_BYTES {
return Err(CapabilityError::InvalidArgs("stdin too large".into()));
}
if let Some(mut stdin_pipe) = child.stdin.take() {
let _ = stdin_pipe.write_all(stdin_content.as_bytes());
}
}
let (exit_status, stdout, stderr, _descendants) =
wait_with_timeout(&mut child, pgid, timeout)
.map_err(|e| CapabilityError::Internal(e.to_string()))?;
let stdout_str = String::from_utf8_lossy(&stdout).to_string();
let stderr_str = String::from_utf8_lossy(&stderr).to_string();
let success = exit_status.success();
let mut out = if success {
Output::ok("completed".into())
} else {
Output::error(
format!("exit code {}", exit_status.code().unwrap_or(-1)),
format!("exit code {}", exit_status.code().unwrap_or(-1)),
)
};
out.data = Some(
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 }),
);
Ok(out)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::capability::Capability;
use std::time::Instant;
#[test]
fn executes_uptime() {
let r = Capability::execute(
&ShellExec,
&serde_json::json!({"cmd": "uptime"}),
&Context {
dry_run: false,
job_id: "test".into(),
working_dir: std::env::temp_dir(),
},
)
.unwrap();
assert_eq!(r.status, "ok");
}
#[test]
fn pipes_work() {
let r = Capability::execute(
&ShellExec,
&serde_json::json!({"cmd": "echo hi | cat"}),
&Context {
dry_run: false,
job_id: "test".into(),
working_dir: std::env::temp_dir(),
},
)
.unwrap();
assert_eq!(r.status, "ok");
assert!(r.data.as_ref().unwrap()["stdout"]
.as_str()
.unwrap()
.contains("hi"));
}
#[test]
fn chaining_works() {
let r = Capability::execute(
&ShellExec,
&serde_json::json!({"cmd": "echo a && echo b"}),
&Context {
dry_run: false,
job_id: "test".into(),
working_dir: std::env::temp_dir(),
},
)
.unwrap();
assert_eq!(r.status, "ok");
}
#[test]
fn blocks_dangerous() {
assert!(Capability::execute(
&ShellExec,
&serde_json::json!({"cmd": "mkfs"}),
&Context {
dry_run: false,
job_id: "test".into(),
working_dir: std::env::temp_dir(),
}
)
.is_err());
}
#[test]
fn blocks_recursive_flag() {
assert!(Capability::execute(
&ShellExec,
&serde_json::json!({"cmd": "rm --recursive /home"}),
&Context {
dry_run: false,
job_id: "test".into(),
working_dir: std::env::temp_dir(),
}
)
.is_err());
}
#[test]
fn blocks_rm_rf_root() {
assert!(Capability::execute(
&ShellExec,
&serde_json::json!({"cmd": "rm -rf /"}),
&Context {
dry_run: false,
job_id: "test".into(),
working_dir: std::env::temp_dir(),
}
)
.is_err());
}
#[test]
fn blocks_rm_no_preserve_root() {
assert!(Capability::execute(
&ShellExec,
&serde_json::json!({"cmd": "rm --no-preserve-root -rf /"}),
&Context {
dry_run: false,
job_id: "test".into(),
working_dir: std::env::temp_dir(),
}
)
.is_err());
}
#[test]
fn blocks_ownership_commands() {
for cmd in &["chown root /tmp/x", "chgrp staff /tmp/x"] {
assert!(
Capability::execute(
&ShellExec,
&serde_json::json!({"cmd": cmd}),
&Context {
dry_run: false,
job_id: "test".into(),
working_dir: std::env::temp_dir(),
}
)
.is_err(),
"should block: {}",
cmd
);
}
}
#[test]
fn blocks_mount_commands() {
for cmd in &["mount /dev/sda1 /mnt", "umount /mnt"] {
assert!(
Capability::execute(
&ShellExec,
&serde_json::json!({"cmd": cmd}),
&Context {
dry_run: false,
job_id: "test".into(),
working_dir: std::env::temp_dir(),
}
)
.is_err(),
"should block: {}",
cmd
);
}
}
#[test]
fn blocks_firewall_commands() {
for cmd in &["iptables -L", "nft list ruleset"] {
assert!(
Capability::execute(
&ShellExec,
&serde_json::json!({"cmd": cmd}),
&Context {
dry_run: false,
job_id: "test".into(),
working_dir: std::env::temp_dir(),
}
)
.is_err(),
"should block: {}",
cmd
);
}
}
#[test]
fn blocks_network_commands_by_default() {
std::env::remove_var("RUNTIMO_ENABLE_NETWORK");
for cmd in &[
"curl http://example.com",
"wget http://example.com",
"nc example.com 80",
] {
assert!(
Capability::execute(
&ShellExec,
&serde_json::json!({"cmd": cmd}),
&Context {
dry_run: false,
job_id: "test".into(),
working_dir: std::env::temp_dir(),
}
)
.is_err(),
"should block network cmd: {}",
cmd
);
}
}
#[test]
fn allows_network_commands_when_enabled() {
std::env::set_var("RUNTIMO_ENABLE_NETWORK", "1");
let r = Capability::execute(
&ShellExec,
&serde_json::json!({"cmd": "curl --version"}),
&Context {
dry_run: false,
job_id: "test".into(),
working_dir: std::env::temp_dir(),
},
);
std::env::remove_var("RUNTIMO_ENABLE_NETWORK");
match r {
Ok(o) => assert_eq!(o.status, "ok", "curl --version should succeed when enabled"),
Err(e) => {
let msg = e.to_string();
assert!(
!msg.contains("network commands blocked"),
"should NOT block network when RUNTIMO_ENABLE_NETWORK=1, got: {}",
msg
);
}
}
}
#[test]
fn enforces_timeout() {
let s = Instant::now();
assert!(Capability::execute(
&ShellExec,
&serde_json::json!({"cmd": "sleep 5", "timeout_secs": 1}),
&Context {
dry_run: false,
job_id: "test".into(),
working_dir: std::env::temp_dir(),
}
)
.is_err());
assert!(s.elapsed().as_secs() < 3);
}
#[test]
fn detokenize_ansi_c_tab_expansion() {
let detok = detokenize_command("$'rm\\t-rf\\t/'");
assert!(detok.contains("rm"));
assert!(detok.contains("-rf"));
assert!(detok.contains('/'));
}
#[test]
fn detokenize_ansi_c_plain_content() {
let detok = detokenize_command("$'rm -rf /'");
assert!(detok.contains("rm -rf /"));
}
#[test]
fn detokenize_ansi_c_hex_escape() {
let detok = detokenize_command("$'\\x72\\x6d'");
assert!(
detok.contains("rm"),
"Hex-encoded 'rm' should decode, got: {:?}",
detok
);
}
#[test]
fn detokenize_ansi_c_unicode_escape() {
let detok = detokenize_command("$'\\u0072\\u006d'");
assert!(
detok.contains("rm"),
"Unicode-encoded 'rm' should decode, got: {:?}",
detok
);
}
#[test]
fn detokenize_ansi_c_octal_escape() {
let detok = detokenize_command("$'\\162\\155'");
assert!(
detok.contains("rm"),
"Octal-encoded 'rm' should decode, got: {:?}",
detok
);
}
#[test]
fn detokenize_ansi_c_newline_expansion() {
let detok = detokenize_command("$'rm\\n-rf\\n/'");
assert!(detok.contains("rm"));
assert!(detok.contains("-rf"));
}
#[test]
fn detokenize_ansi_c_combined_escapes() {
let detok = detokenize_command("$'m\\x6bfs'");
assert!(
detok.contains("mkfs"),
"Should decode to mkfs, got: {:?}",
detok
);
}
#[test]
fn blocks_rm_via_ansi_c_bypass() {
let err = Capability::execute(
&ShellExec,
&serde_json::json!({"cmd": "$'rm\\t-rf\\t/'"}),
&Context {
dry_run: false,
job_id: "test".into(),
working_dir: std::env::temp_dir(),
},
)
.unwrap_err();
let msg = format!("{}", err);
assert!(
msg.contains("recursive rm") || msg.contains("dangerous command blocked"),
"Should block ANSI-C quoted rm, got: {}",
msg
);
}
#[test]
fn blocks_rm_via_ansi_c_hex_bypass() {
let err = Capability::execute(
&ShellExec,
&serde_json::json!({"cmd": "$'\\x72\\x6d' -rf /"}),
&Context {
dry_run: false,
job_id: "test".into(),
working_dir: std::env::temp_dir(),
},
)
.unwrap_err();
assert!(
format!("{}", err).contains("recursive rm")
|| format!("{}", err).contains("rm command blocked"),
"Should block hex-encoded rm bypass"
);
}
#[test]
fn blocks_chmod_via_quote_bypass() {
let err = Capability::execute(
&ShellExec,
&serde_json::json!({"cmd": "c\"hmod\" 777 /"}),
&Context {
dry_run: false,
job_id: "test".into(),
working_dir: std::env::temp_dir(),
},
)
.unwrap_err();
assert!(
format!("{}", err).contains("chmod"),
"Should block c\"hmod\" 777 / (quoted chmod bypass)"
);
}
#[test]
fn detokenize_backslash_newline_continuation() {
let cmd_with_newline = "r\\\nm -rf /";
let detok = detokenize_command(cmd_with_newline);
assert!(
detok.contains("rm"),
"Backslash-newline should be stripped, got: {:?}",
detok
);
assert!(
detok.contains("rm -rf /"),
"Should rejoin tokens across newline, got: {:?}",
detok
);
}
#[test]
fn blocks_rm_via_backslash_newline_bypass() {
let err = Capability::execute(
&ShellExec,
&serde_json::json!({"cmd": "r\\\nm -rf /"}),
&Context {
dry_run: false,
job_id: "test".into(),
working_dir: std::env::temp_dir(),
},
)
.unwrap_err();
assert!(
format!("{}", err).contains("recursive rm")
|| format!("{}", err).contains("rm command blocked"),
"Should block backslash-newline rm bypass"
);
}
#[test]
fn detokenize_multi_pass_stability() {
let detok = detokenize_command("\"'rm'\" -rf /");
assert!(
detok.contains("rm"),
"Multi-pass should converge, got: {:?}",
detok
);
}
#[test]
fn detokenize_roundtrip_idempotent() {
let cmd = "r\"m\" -rf /";
let detok1 = detokenize_command(cmd);
let detok2 = detokenize_command(&detok1);
assert_eq!(detok1, detok2, "Detokenization should be idempotent");
}
#[test]
fn blocks_heredoc() {
let err = Capability::execute(
&ShellExec,
&serde_json::json!({"cmd": "cat <<EOF\nevil\nEOF"}),
&Context {
dry_run: false,
job_id: "test".into(),
working_dir: std::env::temp_dir(),
},
)
.unwrap_err();
let msg = format!("{}", err);
assert!(
msg.contains("heredoc"),
"Should block heredoc (<<), got: {}",
msg
);
}
#[test]
fn blocks_herestring() {
let err = Capability::execute(
&ShellExec,
&serde_json::json!({"cmd": "cat <<<\"hello\""}),
&Context {
dry_run: false,
job_id: "test".into(),
working_dir: std::env::temp_dir(),
},
)
.unwrap_err();
assert!(
format!("{}", err).contains("heredoc"),
"Should block herestring (<<<)"
);
}
#[test]
fn blocks_heredoc_via_quote_bypass() {
let err = Capability::execute(
&ShellExec,
&serde_json::json!({"cmd": "<\"<\"EOF\nevil\nEOF"}),
&Context {
dry_run: false,
job_id: "test".into(),
working_dir: std::env::temp_dir(),
},
)
.unwrap_err();
assert!(
format!("{}", err).contains("heredoc"),
"Should block quoted heredoc bypass"
);
}
#[test]
fn blocks_process_substitution_input() {
let err = Capability::execute(
&ShellExec,
&serde_json::json!({"cmd": "diff <(curl http://evil) <(ls)"}),
&Context {
dry_run: false,
job_id: "test".into(),
working_dir: std::env::temp_dir(),
},
)
.unwrap_err();
assert!(
format!("{}", err).contains("process substitution"),
"Should block <( ) process substitution"
);
}
#[test]
fn blocks_process_substitution_output() {
let err = Capability::execute(
&ShellExec,
&serde_json::json!({"cmd": "echo evil > >(tee /etc/cron.d/backdoor)"}),
&Context {
dry_run: false,
job_id: "test".into(),
working_dir: std::env::temp_dir(),
},
)
.unwrap_err();
assert!(
format!("{}", err).contains("process substitution"),
"Should block >( ) process substitution"
);
}
#[test]
fn blocks_command_substitution_dollar_paren() {
let err = Capability::execute(
&ShellExec,
&serde_json::json!({"cmd": "$(echo rm) -rf /"}),
&Context {
dry_run: false,
job_id: "test".into(),
working_dir: std::env::temp_dir(),
},
)
.unwrap_err();
assert!(
format!("{}", err).contains("command substitution"),
"Should block $( ) command substitution"
);
}
#[test]
fn blocks_command_substitution_backtick() {
let err = Capability::execute(
&ShellExec,
&serde_json::json!({"cmd": "`echo rm` -rf /"}),
&Context {
dry_run: false,
job_id: "test".into(),
working_dir: std::env::temp_dir(),
},
)
.unwrap_err();
assert!(
format!("{}", err).contains("command substitution"),
"Should block backtick command substitution"
);
}
#[test]
fn blocks_command_substitution_in_double_quotes() {
let err = Capability::execute(
&ShellExec,
&serde_json::json!({"cmd": "\"$(echo rm)\" -rf /"}),
&Context {
dry_run: false,
job_id: "test".into(),
working_dir: std::env::temp_dir(),
},
)
.unwrap_err();
assert!(
format!("{}", err).contains("command substitution"),
"Should block $( ) even inside double quotes"
);
}
#[test]
fn detokenize_strips_double_quotes() {
assert_eq!(detokenize_command("r\"m\" -rf /"), "rm -rf /");
}
#[test]
fn detokenize_strips_single_quotes() {
assert_eq!(detokenize_command("'r''m' -rf /"), "rm -rf /");
}
#[test]
fn detokenize_strips_backslash() {
assert_eq!(detokenize_command("r\\m -rf /"), "rm -rf /");
}
#[test]
fn detokenize_mixed_quotes() {
assert_eq!(detokenize_command("r\"m\" -r\"f\""), "rm -rf");
}
#[test]
fn detokenize_preserves_non_quoted() {
assert_eq!(detokenize_command("echo hello"), "echo hello");
assert_eq!(detokenize_command("ls -la /tmp"), "ls -la /tmp");
}
#[test]
fn blocks_rm_via_double_quote_bypass() {
let err = Capability::execute(
&ShellExec,
&serde_json::json!({"cmd": "r\"m\" -rf /"}),
&Context {
dry_run: false,
job_id: "test".into(),
working_dir: std::env::temp_dir(),
},
)
.unwrap_err();
let msg = format!("{}", err);
assert!(
msg.contains("dangerous command blocked") || msg.contains("recursive rm"),
"Should block r\"m\" -rf /, got: {}",
msg
);
}
#[test]
fn blocks_rm_via_single_quote_bypass() {
let err = Capability::execute(
&ShellExec,
&serde_json::json!({"cmd": "'r''m' -rf /"}),
&Context {
dry_run: false,
job_id: "test".into(),
working_dir: std::env::temp_dir(),
},
)
.unwrap_err();
assert!(
format!("{}", err).contains("recursive rm")
|| format!("{}", err).contains("rm command blocked"),
"Should block 'r''m' -rf /"
);
}
#[test]
fn blocks_rm_via_backslash_bypass() {
let err = Capability::execute(
&ShellExec,
&serde_json::json!({"cmd": "r\\m -rf /"}),
&Context {
dry_run: false,
job_id: "test".into(),
working_dir: std::env::temp_dir(),
},
)
.unwrap_err();
assert!(
format!("{}", err).contains("recursive rm")
|| format!("{}", err).contains("rm command blocked"),
"Should block r\\m -rf /"
);
}
#[test]
fn blocks_dd_via_quote_bypass() {
let err = Capability::execute(
&ShellExec,
&serde_json::json!({"cmd": "d\"d\" if=/dev/zero"}),
&Context {
dry_run: false,
job_id: "test".into(),
working_dir: std::env::temp_dir(),
},
)
.unwrap_err();
assert!(
format!("{}", err).contains("dd"),
"Should block d\"d\" (dd bypass)"
);
}
#[test]
fn blocks_cat_outside_allowed() {
let err = Capability::execute(
&ShellExec,
&serde_json::json!({"cmd": "cat /etc/passwd"}),
&Context {
dry_run: false,
job_id: "test".into(),
working_dir: std::env::temp_dir(),
},
)
.unwrap_err();
assert!(
format!("{}", err).contains("outside allowed directories"),
"Should block cat /etc/passwd"
);
}
#[test]
fn blocks_ls_tilde_ssh() {
let err = Capability::execute(
&ShellExec,
&serde_json::json!({"cmd": "ls ~/../../etc/passwd"}),
&Context {
dry_run: false,
job_id: "test".into(),
working_dir: std::env::temp_dir(),
},
)
.unwrap_err();
let msg = format!("{}", err);
assert!(
msg.contains("outside allowed") || msg.contains("traversal"),
"Should block ls ~/../../etc/passwd, got: {}",
msg
);
}
#[test]
fn blocks_path_to_root() {
let err = Capability::execute(
&ShellExec,
&serde_json::json!({"cmd": "cat /root/.bashrc"}),
&Context {
dry_run: false,
job_id: "test".into(),
working_dir: std::env::temp_dir(),
},
)
.unwrap_err();
assert!(
format!("{}", err).contains("outside allowed directories"),
"Should block cat /root/.bashrc"
);
}
#[test]
fn allows_cat_in_tmp() {
let r = Capability::execute(
&ShellExec,
&serde_json::json!({"cmd": "echo test > /tmp/runtimo_path_test.txt && cat /tmp/runtimo_path_test.txt"}),
&Context {
dry_run: false,
job_id: "test".into(),
working_dir: std::env::temp_dir(),
},
);
let _ = std::fs::remove_file("/tmp/runtimo_path_test.txt");
match r {
Ok(o) => assert_eq!(o.status, "ok", "Should allow cat in /tmp"),
Err(e) => {
let msg = format!("{}", e);
assert!(
!msg.contains("outside allowed"),
"Should NOT block /tmp path, got: {}",
msg
);
}
}
}
#[test]
fn allows_cat_in_home() {
let home = std::env::var("HOME").unwrap_or_else(|_| "/home/user".to_string());
let test_path = format!("{}/runtimo_home_path_test.txt", home);
let cmd = format!("echo ok > {} && cat {}", test_path, test_path);
let r = Capability::execute(
&ShellExec,
&serde_json::json!({"cmd": cmd}),
&Context {
dry_run: false,
job_id: "test".into(),
working_dir: std::env::temp_dir(),
},
);
let _ = std::fs::remove_file(&test_path);
match r {
Ok(o) => assert_eq!(o.status, "ok", "Should allow cat in HOME"),
Err(e) => {
let msg = format!("{}", e);
assert!(
!msg.contains("outside allowed"),
"Should NOT block HOME path, got: {}",
msg
);
}
}
}
#[test]
fn blocks_var_expanded_path_outside_allowed() {
let err = Capability::execute(
&ShellExec,
&serde_json::json!({"cmd": "cat $HOME/../../etc/shadow"}),
&Context {
dry_run: false,
job_id: "test".into(),
working_dir: std::env::temp_dir(),
},
)
.unwrap_err();
let msg = format!("{}", err);
assert!(
msg.contains("outside allowed") || msg.contains("traversal"),
"Should block $HOME/../../etc/shadow, got: {}",
msg
);
}
#[test]
fn blocks_var_brace_expanded_path() {
let err = Capability::execute(
&ShellExec,
&serde_json::json!({"cmd": "cat ${HOME}/../../etc/shadow"}),
&Context {
dry_run: false,
job_id: "test".into(),
working_dir: std::env::temp_dir(),
},
)
.unwrap_err();
let msg = format!("{}", err);
assert!(
msg.contains("outside allowed") || msg.contains("traversal"),
"Should block brace-syntax var expansion"
);
}
#[test]
fn test_expand_shell_vars_resolves_home() {
let expanded = expand_shell_vars("$HOME/.ssh");
let home = std::env::var("HOME").unwrap_or_default();
assert!(
expanded.starts_with(&home),
"Should expand $HOME, got: {}",
expanded
);
assert!(
expanded.ends_with("/.ssh"),
"Should keep suffix, got: {}",
expanded
);
}
#[test]
fn test_expand_shell_vars_brace_syntax() {
let expanded = expand_shell_vars("${HOME}/.ssh");
let home = std::env::var("HOME").unwrap_or_default();
assert!(
expanded.starts_with(&home),
"Should expand brace-syntax var"
);
}
#[test]
fn blocks_redirect_to_outside_path() {
let err = Capability::execute(
&ShellExec,
&serde_json::json!({"cmd": "echo evil >/etc/cron.d/backdoor"}),
&Context {
dry_run: false,
job_id: "test".into(),
working_dir: std::env::temp_dir(),
},
)
.unwrap_err();
assert!(
format!("{}", err).contains("outside allowed directories"),
"Should block redirect to /etc/cron.d/backdoor"
);
}
#[test]
fn blocks_append_redirect_to_outside_path() {
let err = Capability::execute(
&ShellExec,
&serde_json::json!({"cmd": "echo evil >>/etc/hosts"}),
&Context {
dry_run: false,
job_id: "test".into(),
working_dir: std::env::temp_dir(),
},
)
.unwrap_err();
assert!(
format!("{}", err).contains("outside allowed directories"),
"Should block append redirect to /etc/hosts"
);
}
#[test]
fn blocks_stderr_redirect_outside() {
let err = Capability::execute(
&ShellExec,
&serde_json::json!({"cmd": "ls 2>/etc/malicious"}),
&Context {
dry_run: false,
job_id: "test".into(),
working_dir: std::env::temp_dir(),
},
)
.unwrap_err();
assert!(
format!("{}", err).contains("outside allowed directories"),
"Should block 2> redirect to /etc/malicious"
);
}
#[test]
fn allows_redirect_to_allowed_path() {
let r = Capability::execute(
&ShellExec,
&serde_json::json!({"cmd": "echo hello >/tmp/runtimo_redirect_test.txt"}),
&Context {
dry_run: false,
job_id: "test".into(),
working_dir: std::env::temp_dir(),
},
);
let _ = std::fs::remove_file("/tmp/runtimo_redirect_test.txt");
match r {
Ok(o) => assert_eq!(o.status, "ok"),
Err(e) => {
assert!(
!format!("{}", e).contains("outside allowed"),
"Should NOT block redirect to /tmp, got: {}",
e
);
}
}
}
#[test]
fn blocks_relative_parent_traversal() {
let err = Capability::execute(
&ShellExec,
&serde_json::json!({"cmd": "cat ../../etc/passwd"}),
&Context {
dry_run: false,
job_id: "test".into(),
working_dir: std::env::temp_dir(),
},
)
.unwrap_err();
assert!(
format!("{}", err).contains("outside allowed directories"),
"Should block relative path traversal to /etc/passwd"
);
}
#[test]
fn blocks_deep_relative_traversal() {
let err = Capability::execute(
&ShellExec,
&serde_json::json!({"cmd": "cat ./../../../etc/shadow"}),
&Context {
dry_run: false,
job_id: "test".into(),
working_dir: std::env::temp_dir(),
},
)
.unwrap_err();
assert!(
format!("{}", err).contains("outside allowed directories"),
"Should block deep relative traversal"
);
}
#[test]
fn allows_relative_within_allowed() {
let test_file = "/tmp/runtimo_relative_allowed_test.txt";
let r = Capability::execute(
&ShellExec,
&serde_json::json!({"cmd": format!("echo ok > {}", test_file)}),
&Context {
dry_run: false,
job_id: "test".into(),
working_dir: std::env::temp_dir(),
},
);
let _ = std::fs::remove_file(test_file);
match r {
Ok(o) => assert_eq!(o.status, "ok", "Should allow path within /tmp"),
Err(e) => {
let msg = format!("{}", e);
assert!(
!msg.contains("outside allowed"),
"Should NOT block /tmp path, got: {}",
msg
);
}
}
}
#[test]
fn blocks_env_command() {
let err = Capability::execute(
&ShellExec,
&serde_json::json!({"cmd": "env"}),
&Context {
dry_run: false,
job_id: "test".into(),
working_dir: std::env::temp_dir(),
},
)
.unwrap_err();
assert!(
format!("{}", err).contains("environment variable dumping"),
"Should block `env` command"
);
}
#[test]
fn blocks_printenv_command() {
let err = Capability::execute(
&ShellExec,
&serde_json::json!({"cmd": "printenv"}),
&Context {
dry_run: false,
job_id: "test".into(),
working_dir: std::env::temp_dir(),
},
)
.unwrap_err();
assert!(
format!("{}", err).contains("environment variable dumping"),
"Should block `printenv` command"
);
}
#[test]
fn blocks_set_command() {
let err = Capability::execute(
&ShellExec,
&serde_json::json!({"cmd": "set"}),
&Context {
dry_run: false,
job_id: "test".into(),
working_dir: std::env::temp_dir(),
},
)
.unwrap_err();
assert!(
format!("{}", err).contains("environment variable dumping"),
"Should block `set` command"
);
}
#[test]
fn blocks_export_command() {
let err = Capability::execute(
&ShellExec,
&serde_json::json!({"cmd": "export"}),
&Context {
dry_run: false,
job_id: "test".into(),
working_dir: std::env::temp_dir(),
},
)
.unwrap_err();
assert!(
format!("{}", err).contains("environment variable dumping"),
"Should block `export` command"
);
}
#[test]
fn blocks_export_with_assignment() {
let err = Capability::execute(
&ShellExec,
&serde_json::json!({"cmd": "export FOO=bar"}),
&Context {
dry_run: false,
job_id: "test".into(),
working_dir: std::env::temp_dir(),
},
)
.unwrap_err();
assert!(
format!("{}", err).contains("environment variable dumping"),
"Should block `export FOO=bar` (export with assignment)"
);
}
#[test]
fn blocks_declare_p_command() {
let err = Capability::execute(
&ShellExec,
&serde_json::json!({"cmd": "declare -p"}),
&Context {
dry_run: false,
job_id: "test".into(),
working_dir: std::env::temp_dir(),
},
)
.unwrap_err();
assert!(
format!("{}", err).contains("environment variable dumping"),
"Should block `declare -p` command"
);
}
#[test]
fn blocks_env_via_quote_bypass() {
let err = Capability::execute(
&ShellExec,
&serde_json::json!({"cmd": "e\"n\"v"}),
&Context {
dry_run: false,
job_id: "test".into(),
working_dir: std::env::temp_dir(),
},
)
.unwrap_err();
assert!(
format!("{}", err).contains("environment variable dumping"),
"Should block e\"n\"v (quoted env bypass)"
);
}
#[test]
fn allows_harmless_command_with_env_check() {
let r = Capability::execute(
&ShellExec,
&serde_json::json!({"cmd": "echo hello"}),
&Context {
dry_run: false,
job_id: "test".into(),
working_dir: std::env::temp_dir(),
},
)
.unwrap();
assert_eq!(r.status, "ok");
assert!(r.data.as_ref().unwrap()["stdout"]
.as_str()
.unwrap()
.contains("hello"));
}
#[test]
fn is_sensitive_env_var_detects_aws() {
assert!(is_sensitive_env_var("AWS_ACCESS_KEY_ID"));
assert!(is_sensitive_env_var("AWS_SECRET_ACCESS_KEY"));
assert!(is_sensitive_env_var("aws_session_token")); }
#[test]
fn is_sensitive_env_var_detects_suffixes() {
assert!(is_sensitive_env_var("MYAPP_API_KEY"));
assert!(is_sensitive_env_var("GITHUB_TOKEN"));
assert!(is_sensitive_env_var("DB_PASSWORD"));
assert!(is_sensitive_env_var("STRIPE_SECRET_KEY"));
}
#[test]
fn is_sensitive_env_var_allows_safe() {
assert!(!is_sensitive_env_var("HOME"));
assert!(!is_sensitive_env_var("USER"));
assert!(!is_sensitive_env_var("PATH"));
assert!(!is_sensitive_env_var("TERM"));
assert!(!is_sensitive_env_var("LANG"));
assert!(!is_sensitive_env_var("RUNTIMO_ENABLE_NETWORK"));
}
#[test]
fn is_sensitive_env_var_allows_known_non_secret_suffix() {
assert!(!is_sensitive_env_var("FOREIGN_KEY"));
assert!(!is_sensitive_env_var("PRIMARY_KEY"));
assert!(!is_sensitive_env_var("PUBLIC_KEY"));
assert!(!is_sensitive_env_var("BASE_URL"));
}
#[test]
fn is_sensitive_env_var_detects_ld_preload() {
assert!(is_sensitive_env_var("LD_PRELOAD"));
assert!(is_sensitive_env_var("LD_LIBRARY_PATH"));
assert!(is_sensitive_env_var("LD_DEBUG"));
assert!(is_sensitive_env_var("LD_BIND_NOW"));
}
#[test]
fn is_sensitive_env_var_detects_dyld() {
assert!(is_sensitive_env_var("DYLD_INSERT_LIBRARIES"));
assert!(is_sensitive_env_var("DYLD_LIBRARY_PATH"));
}
#[test]
fn sanitized_env_strips_secrets() {
std::env::set_var("RUNTIMO_TEST_SECRET_KEY", "test-value");
let env = sanitized_env();
std::env::remove_var("RUNTIMO_TEST_SECRET_KEY");
assert!(
!env.iter()
.map(|(k, _)| k.as_str())
.any(|x| x == "RUNTIMO_TEST_SECRET_KEY"),
"RUNTIMO_TEST_SECRET_KEY should be stripped from env"
);
}
#[test]
fn sanitized_env_preserves_safe() {
let env = sanitized_env();
let keys: Vec<&str> = env.iter().map(|(k, _)| k.as_str()).collect();
assert!(keys.contains(&"HOME"), "HOME should be preserved");
assert!(keys.contains(&"USER"), "USER should be preserved");
}
}