use std::sync::Arc;
use async_trait::async_trait;
use oxi_sdk::{AgentTool, AgentToolResult, ToolContext};
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use tokio::sync::oneshot;
use crate::access_manager::AccessManager;
use crate::config::ExecConfig;
const SHELL_METACHARS: &[char] = &[
'|', '&', ';', '$', '`', '<', '>', '(', ')', '{', '}', '\n', '\r', '\0',
];
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExecResult {
pub stdout: String,
pub stderr: String,
pub exit_code: i32,
pub duration_ms: u64,
}
pub struct ExecTool {
config: Arc<ExecConfig>,
access: Arc<Mutex<AccessManager>>,
agent_name: Option<String>,
}
impl ExecTool {
pub fn new(config: Arc<ExecConfig>, access: Arc<Mutex<AccessManager>>) -> Self {
Self {
config,
access,
agent_name: None,
}
}
pub fn from_kernel(kernel: &crate::kernel_handle::KernelHandle) -> Self {
Self::for_agent(
Arc::new(kernel.exec.config().clone()),
kernel.exec.access_manager().clone(),
"oxios-agent".to_string(),
)
}
pub fn for_agent(
config: Arc<ExecConfig>,
access: Arc<Mutex<AccessManager>>,
agent_name: String,
) -> Self {
Self {
config,
access,
agent_name: Some(agent_name),
}
}
pub async fn shell_exec(&self, command: &str, timeout_ms: u64) -> Result<ExecResult, String> {
if !self.config.allow_shell_mode {
return Err(
"shell_exec: shell mode is disabled by configuration (allow_shell_mode = false). \
Use mode='structured' instead, or set allow_shell_mode=true in config.toml"
.to_string(),
);
}
if command.trim().is_empty() {
return Err("shell_exec: command must not be empty".to_string());
}
if let Some(ref name) = self.agent_name {
let mut access = self.access.lock();
if !access.can_use_tool(name, "bash") {
return Err(format!(
"shell_exec: agent '{}' is not allowed to execute 'bash'",
name
));
}
tracing::info!(
agent = %name,
mode = "shell",
command = %command.chars().take(200).collect::<String>(),
"ExecTool: executing shell command (shell mode enabled)",
);
} else {
tracing::warn!(
mode = "shell",
command = %command.chars().take(200).collect::<String>(),
"ExecTool: shell mode executing without agent context",
);
}
let effective_timeout = timeout_ms.clamp(1_000, self.config.max_timeout_secs * 1_000);
let start = std::time::Instant::now();
let result = tokio::time::timeout(
std::time::Duration::from_millis(effective_timeout),
tokio::process::Command::new("bash")
.arg("-c")
.arg(command)
.env_clear()
.env("HOME", std::env::var("HOME").unwrap_or_default())
.env("USER", std::env::var("USER").unwrap_or_default())
.env("LOGNAME", std::env::var("LOGNAME").unwrap_or_default())
.env("PATH", std::env::var("PATH").unwrap_or_default())
.env(
"LANG",
std::env::var("LANG").unwrap_or_else(|_| "en_US.UTF-8".to_string()),
)
.env("TERM", "dumb")
.output(),
)
.await;
let duration_ms = start.elapsed().as_millis() as u64;
match result {
Ok(Ok(output)) => Ok(ExecResult {
stdout: String::from_utf8_lossy(&output.stdout).to_string(),
stderr: String::from_utf8_lossy(&output.stderr).to_string(),
exit_code: output.status.code().unwrap_or(-1),
duration_ms,
}),
Ok(Err(e)) => Err(format!("shell execution error: {e}")),
Err(_) => Err(format!(
"shell command timed out after {effective_timeout}ms"
)),
}
}
pub async fn structured_exec(
&self,
binary: &str,
args: Vec<String>,
timeout_ms: u64,
) -> Result<ExecResult, String> {
if let Some(ref name) = self.agent_name {
let mut access = self.access.lock();
if !access.can_use_tool(name, binary) {
return Err(format!(
"structured_exec: agent '{}' is not allowed to execute '{}'",
name, binary
));
}
}
tracing::debug!(mode = "structured", binary = %binary, args = ?args, "ExecTool executing");
if binary.contains("..") {
return Err("structured_exec: path traversal in binary name".to_string());
}
if binary.contains('/') {
return Err("structured_exec: binary must be a bare name, not a path".to_string());
}
if !self.config.is_binary_allowed(binary) {
return Err(format!(
"structured_exec: binary '{binary}' is not in the allowlist"
));
}
if has_metacharacters(&args) {
return Err(
"structured_exec: shell metacharacters or path traversal not allowed in arguments"
.to_string(),
);
}
let effective_timeout = timeout_ms.clamp(1_000, self.config.max_timeout_secs * 1_000);
let start = std::time::Instant::now();
let result = tokio::time::timeout(
std::time::Duration::from_millis(effective_timeout),
tokio::process::Command::new(binary)
.args(&args)
.env_clear()
.env("HOME", std::env::var("HOME").unwrap_or_default())
.env("USER", std::env::var("USER").unwrap_or_default())
.env("LOGNAME", std::env::var("LOGNAME").unwrap_or_default())
.env("PATH", std::env::var("PATH").unwrap_or_default())
.env(
"LANG",
std::env::var("LANG").unwrap_or_else(|_| "en_US.UTF-8".to_string()),
)
.env("TERM", "dumb")
.output(),
)
.await;
let duration_ms = start.elapsed().as_millis() as u64;
match result {
Ok(Ok(output)) => Ok(ExecResult {
stdout: String::from_utf8_lossy(&output.stdout).to_string(),
stderr: String::from_utf8_lossy(&output.stderr).to_string(),
exit_code: output.status.code().unwrap_or(-1),
duration_ms,
}),
Ok(Err(e)) => Err(format!("structured execution error: {e}")),
Err(_) => Err(format!(
"structured command timed out after {effective_timeout}ms"
)),
}
}
}
fn has_metacharacters(args: &[String]) -> bool {
for arg in args {
if arg.contains("..") {
return true;
}
if SHELL_METACHARS.iter().any(|&c| arg.contains(c)) {
return true;
}
}
false
}
fn format_exec_output(result: &ExecResult) -> String {
let mut output = String::new();
if result.stdout.is_empty() && result.stderr.is_empty() {
output.push_str("(no output)");
} else {
if !result.stdout.is_empty() {
output.push_str(&result.stdout);
}
if !result.stderr.is_empty() && !result.stdout.is_empty() {
output.push('\n');
}
if !result.stderr.is_empty() {
output.push_str(&result.stderr);
}
}
if result.exit_code != 0 {
output.push_str(&format!(
"\n\nCommand exited with code {}",
result.exit_code
));
}
let secs = result.duration_ms / 1000;
let millis = result.duration_ms % 1000;
if secs >= 60 {
let mins = secs / 60;
let remain_secs = secs % 60;
output.push_str(&format!(
"\n\nTook {}m {:.1}s",
mins,
remain_secs as f64 + millis as f64 / 1000.0
));
} else {
output.push_str(&format!(
"\n\nTook {:.1}s",
secs as f64 + millis as f64 / 1000.0
));
}
output
}
impl std::fmt::Debug for ExecTool {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ExecTool").finish()
}
}
#[async_trait]
impl AgentTool for ExecTool {
fn name(&self) -> &str {
"exec"
}
fn label(&self) -> &str {
"Exec"
}
fn description(&self) -> &'static str {
"Execute a command. Use mode='shell' for raw shell strings (pipelines, redirects) or mode='structured' for a specific binary+args with allowlist security."
}
fn parameters_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"mode": {
"type": "string",
"enum": ["shell", "structured"],
"description": "Execution mode: 'shell' for bash -c <command>, 'structured' for binary+args with allowlist enforcement"
},
"command": {
"type": "string",
"description": "Shell command string (mode='shell' only)"
},
"binary": {
"type": "string",
"description": "Binary name (mode='structured' only, must be in allowlist)"
},
"args": {
"type": "array",
"items": { "type": "string" },
"description": "Binary arguments (mode='structured' only)"
},
"timeout": {
"type": "integer",
"description": "Timeout in seconds",
"default": 120
}
},
"required": ["mode"]
})
}
async fn execute(
&self,
_tool_call_id: &str,
params: Value,
_signal: Option<oneshot::Receiver<()>>,
_ctx: &ToolContext,
) -> Result<AgentToolResult, String> {
let mode = params.get("mode").and_then(|v| v.as_str()).ok_or_else(|| {
"Missing required parameter: mode (expected 'shell' or 'structured')".to_string()
})?;
let timeout_secs = params
.get("timeout")
.and_then(|v| v.as_u64())
.unwrap_or(self.config.default_timeout_secs);
let timeout_ms = (timeout_secs * 1000).min(self.config.max_timeout_secs * 1000);
match mode {
"shell" => {
let command = match params.get("command").and_then(|v| v.as_str()) {
Some(c) => c,
None => {
return Ok(AgentToolResult::error(
"shell mode requires 'command' parameter",
))
}
};
match self.shell_exec(command, timeout_ms).await {
Ok(result) => {
let output = format_exec_output(&result);
if result.exit_code == 0 {
Ok(AgentToolResult::success(output))
} else {
Ok(AgentToolResult::error(output))
}
}
Err(e) => Ok(AgentToolResult::error(format!("exec (shell): {e}"))),
}
}
"structured" => {
let binary = match params.get("binary").and_then(|v| v.as_str()) {
Some(b) => b,
None => {
return Ok(AgentToolResult::error(
"structured mode requires 'binary' parameter",
))
}
};
let args: Vec<String> = params
.get("args")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
.unwrap_or_default();
match self.structured_exec(binary, args, timeout_ms).await {
Ok(result) => {
let output = format_exec_output(&result);
if result.exit_code == 0 {
Ok(AgentToolResult::success(output))
} else {
Ok(AgentToolResult::error(output))
}
}
Err(e) => Ok(AgentToolResult::error(format!("exec (structured): {e}"))),
}
}
other => Err(format!(
"Invalid mode '{other}': expected 'shell' or 'structured'"
)),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_tool(allowed_commands: Vec<&str>) -> ExecTool {
let mut config = ExecConfig::default();
config.allowed_commands = allowed_commands.into_iter().map(String::from).collect();
config.allow_shell_mode = true; ExecTool::new(Arc::new(config), Arc::new(Mutex::new(AccessManager::new())))
}
#[tokio::test]
async fn test_shell_exec_echo() {
let tool = make_tool(vec![]);
let result = tool.shell_exec("echo hello", 5_000).await;
assert!(result.is_ok());
let r = result.unwrap();
assert_eq!(r.exit_code, 0);
assert!(r.stdout.contains("hello"));
assert!(r.duration_ms < 5_000);
}
#[tokio::test]
async fn test_shell_exec_pipeline() {
let tool = make_tool(vec![]);
let result = tool.shell_exec("echo foo | tr f b", 5_000).await;
assert!(result.is_ok());
let r = result.unwrap();
assert_eq!(r.exit_code, 0);
assert!(r.stdout.contains("boo"));
}
#[tokio::test]
async fn test_shell_exec_nonzero_exit() {
let tool = make_tool(vec![]);
let result = tool.shell_exec("exit 42", 5_000).await;
assert!(result.is_ok());
assert_eq!(result.unwrap().exit_code, 42);
}
#[tokio::test]
async fn test_shell_exec_empty_command() {
let tool = make_tool(vec![]);
let result = tool.shell_exec(" ", 5_000).await;
assert!(result.is_err());
assert!(result.unwrap_err().contains("must not be empty"));
}
#[tokio::test]
async fn test_shell_exec_timeout() {
let tool = make_tool(vec![]);
let result = tool.shell_exec("sleep 300", 1_000).await;
assert!(result.is_err());
assert!(result.unwrap_err().contains("timed out"));
}
#[tokio::test]
async fn test_structured_exec_echo() {
let tool = make_tool(vec!["echo"]);
let result = tool
.structured_exec("echo", vec!["hello".into()], 5_000)
.await;
assert!(result.is_ok());
let r = result.unwrap();
assert_eq!(r.exit_code, 0);
assert!(r.stdout.contains("hello"));
}
#[tokio::test]
async fn test_structured_exec_blocked_binary() {
let tool = make_tool(vec!["echo"]);
let result = tool
.structured_exec("rm", vec!["-rf".into(), "/".into()], 5_000)
.await;
assert!(result.is_err());
assert!(result.unwrap_err().contains("not in the allowlist"));
}
#[tokio::test]
async fn test_structured_exec_path_binary() {
let tool = make_tool(vec![]);
let result = tool.structured_exec("/usr/bin/echo", vec![], 5_000).await;
assert!(result.is_err());
assert!(result.unwrap_err().contains("bare name"));
}
#[tokio::test]
async fn test_structured_exec_traversal_binary() {
let tool = make_tool(vec![]);
let result = tool.structured_exec("../bin/evil", vec![], 5_000).await;
assert!(result.is_err());
assert!(result.unwrap_err().contains("path traversal"));
}
#[tokio::test]
async fn test_structured_exec_metachar_args() {
let tool = make_tool(vec!["echo"]);
let result = tool
.structured_exec("echo", vec!["foo; rm -rf /".into()], 5_000)
.await;
assert!(result.is_err());
assert!(result.unwrap_err().contains("metacharacters"));
}
#[tokio::test]
async fn test_structured_exec_path_traversal_args() {
let tool = make_tool(vec!["cat"]);
let result = tool
.structured_exec("cat", vec!["../etc/passwd".into()], 5_000)
.await;
assert!(result.is_err());
assert!(result.unwrap_err().contains("metacharacters"));
}
#[tokio::test]
async fn test_structured_exec_clean_args() {
let tool = make_tool(vec!["echo"]);
let result = tool
.structured_exec("echo", vec!["hello".into(), "world".into()], 5_000)
.await;
assert!(result.is_ok());
let r = result.unwrap();
assert_eq!(r.exit_code, 0);
assert!(r.stdout.contains("hello world"));
}
#[test]
fn test_name_and_label() {
let tool = make_tool(vec![]);
assert_eq!(tool.name(), "exec");
assert_eq!(tool.label(), "Exec");
}
#[test]
fn test_parameters_schema() {
let tool = make_tool(vec![]);
let schema = tool.parameters_schema();
let props = schema["properties"].as_object().unwrap();
assert!(props.contains_key("mode"));
assert!(props.contains_key("command"));
assert!(props.contains_key("binary"));
assert!(props.contains_key("args"));
assert!(props.contains_key("timeout"));
let required = schema["required"].as_array().unwrap();
assert!(required.iter().any(|r| r.as_str() == Some("mode")));
}
#[tokio::test]
async fn test_agent_tool_shell_mode() {
let tool = make_tool(vec![]);
let result = tool
.execute(
"test-1",
json!({ "mode": "shell", "command": "echo hello" }),
None,
&ToolContext::default(),
)
.await;
assert!(result.is_ok());
let r = result.unwrap();
assert!(r.success, "Expected success, got: {}", r.output);
assert!(r.output.contains("hello"));
}
#[tokio::test]
async fn test_agent_tool_structured_mode() {
let tool = make_tool(vec!["echo"]);
let result = tool
.execute(
"test-2",
json!({ "mode": "structured", "binary": "echo", "args": ["hi"] }),
None,
&ToolContext::default(),
)
.await;
assert!(result.is_ok());
let r = result.unwrap();
assert!(r.success, "Expected success, got: {}", r.output);
assert!(r.output.contains("hi"));
}
#[tokio::test]
async fn test_agent_tool_missing_mode() {
let tool = make_tool(vec![]);
let result = tool
.execute(
"test-3",
json!({ "command": "echo hi" }),
None,
&ToolContext::default(),
)
.await;
assert!(result.is_err());
assert!(result
.unwrap_err()
.contains("Missing required parameter: mode"));
}
#[tokio::test]
async fn test_agent_tool_invalid_mode() {
let tool = make_tool(vec![]);
let result = tool
.execute(
"test-4",
json!({ "mode": "docker" }),
None,
&ToolContext::default(),
)
.await;
assert!(result.is_err());
assert!(result.unwrap_err().contains("Invalid mode"));
}
#[tokio::test]
async fn test_agent_tool_shell_missing_command() {
let tool = make_tool(vec![]);
let result = tool
.execute(
"test-5",
json!({ "mode": "shell" }),
None,
&ToolContext::default(),
)
.await;
assert!(result.is_ok());
let r = result.unwrap();
assert!(!r.success);
assert!(r.output.contains("shell mode requires 'command' parameter"));
}
#[tokio::test]
async fn test_agent_tool_structured_missing_binary() {
let tool = make_tool(vec![]);
let result = tool
.execute(
"test-6",
json!({ "mode": "structured" }),
None,
&ToolContext::default(),
)
.await;
assert!(result.is_ok());
let r = result.unwrap();
assert!(!r.success);
assert!(r
.output
.contains("structured mode requires 'binary' parameter"));
}
#[tokio::test]
async fn test_agent_tool_nonzero_exit() {
let tool = make_tool(vec![]);
let result = tool
.execute(
"test-7",
json!({ "mode": "shell", "command": "exit 7" }),
None,
&ToolContext::default(),
)
.await;
assert!(result.is_ok());
let r = result.unwrap();
assert!(!r.success);
assert!(r.output.contains("exited with code 7"));
}
#[test]
fn test_format_exec_output_success() {
let result = ExecResult {
stdout: "hello".to_string(),
stderr: String::new(),
exit_code: 0,
duration_ms: 1_500,
};
let output = format_exec_output(&result);
assert!(output.contains("hello"));
assert!(output.contains("Took 1.5s"));
assert!(!output.contains("exited with code"));
}
#[test]
fn test_format_exec_output_failure() {
let result = ExecResult {
stdout: String::new(),
stderr: "error!".to_string(),
exit_code: 1,
duration_ms: 500,
};
let output = format_exec_output(&result);
assert!(output.contains("error!"));
assert!(output.contains("exited with code 1"));
}
#[test]
fn test_format_exec_output_no_output() {
let result = ExecResult {
stdout: String::new(),
stderr: String::new(),
exit_code: 0,
duration_ms: 100,
};
let output = format_exec_output(&result);
assert!(output.contains("(no output)"));
}
#[test]
fn test_format_exec_output_minutes() {
let result = ExecResult {
stdout: "done".to_string(),
stderr: String::new(),
exit_code: 0,
duration_ms: 125_000, };
let output = format_exec_output(&result);
assert!(output.contains("Took 2m 5.0s"));
}
#[test]
fn test_has_metacharacters_clean() {
assert!(!has_metacharacters(&["hello".into(), "world".into()]));
}
#[test]
fn test_has_metacharacters_semicolon() {
assert!(has_metacharacters(&["foo;bar".into()]));
}
#[test]
fn test_has_metacharacters_pipe() {
assert!(has_metacharacters(&["a | b".into()]));
}
#[test]
fn test_has_metacharacters_dollar() {
assert!(has_metacharacters(&["$(whoami)".into()]));
}
#[test]
fn test_has_metacharacters_backtick() {
assert!(has_metacharacters(&["`id`".into()]));
}
#[test]
fn test_has_metacharacters_traversal() {
assert!(has_metacharacters(&["../etc/passwd".into()]));
}
fn make_agent_tool(agent_name: &str, allowed_tools: &[&str]) -> ExecTool {
let mut config = ExecConfig::default();
config.allow_shell_mode = true; let mut access = AccessManager::new();
{
let perms = access.get_or_create_permissions(agent_name);
perms.allowed_tools.clear();
for tool in allowed_tools {
perms.allow_tool(tool);
}
}
ExecTool::for_agent(
Arc::new(config),
Arc::new(Mutex::new(access)),
agent_name.to_string(),
)
}
#[tokio::test]
async fn test_for_agent_structured_exec_allowed() {
let tool = make_agent_tool("test-agent", &["echo", "ls"]);
let result = tool
.structured_exec("echo", vec!["hello".into()], 5_000)
.await;
assert!(result.is_ok(), "Allowed binary should succeed");
let r = result.unwrap();
assert_eq!(r.exit_code, 0);
assert!(r.stdout.contains("hello"));
}
#[tokio::test]
async fn test_for_agent_structured_exec_denied() {
let tool = make_agent_tool("test-agent", &["ls"]); let result = tool
.structured_exec("echo", vec!["hello".into()], 5_000)
.await;
assert!(result.is_err());
let err = result.unwrap_err();
assert!(
err.contains("not allowed to execute"),
"Error should mention denial: {err}"
);
assert!(
err.contains("echo"),
"Error should name the denied binary: {err}"
);
}
#[tokio::test]
async fn test_for_agent_shell_exec_allowed() {
let tool = make_agent_tool("test-agent", &["bash"]);
let result = tool.shell_exec("echo hello", 5_000).await;
assert!(
result.is_ok(),
"Agent with 'bash' permission should succeed"
);
assert!(result.unwrap().stdout.contains("hello"));
}
#[tokio::test]
async fn test_for_agent_shell_exec_denied() {
let tool = make_agent_tool("test-agent", &["ls"]); let result = tool.shell_exec("echo hello", 5_000).await;
assert!(result.is_err());
let err = result.unwrap_err();
assert!(
err.contains("not allowed to execute"),
"Error should mention denial: {err}"
);
assert!(err.contains("bash"), "Error should name 'bash': {err}");
}
#[tokio::test]
async fn test_no_agent_name_bypasses_access_control() {
let mut config = ExecConfig::default();
config.allow_shell_mode = true; let access = AccessManager::new(); let tool = ExecTool::new(Arc::new(config), Arc::new(Mutex::new(access)));
let result = tool.shell_exec("echo unrestricted", 5_000).await;
assert!(
result.is_ok(),
"Shell mode enabled + no agent_name = unrestricted execution"
);
}
#[test]
fn test_agent_name_set_correctly() {
let tool = make_agent_tool("my-agent", &[]);
assert_eq!(tool.agent_name.as_deref(), Some("my-agent"));
}
#[test]
fn test_new_has_no_agent_name() {
let tool = make_tool(vec![]);
assert!(tool.agent_name.is_none());
}
}