use tokio::process::Command;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use super::base::{Tool, ToolError};
use crate::context::Context;
#[derive(Debug, Serialize)]
pub struct RunCommandOutput {
pub command: String,
pub args: Vec<String>,
pub exit_code: Option<i32>,
pub stdout: String,
pub stderr: String,
}
#[derive(Deserialize, JsonSchema)]
pub struct RunCommand {
pub command: String,
pub args: Vec<String>,
}
impl Tool for RunCommand {
type Output = RunCommandOutput;
fn name() -> &'static str {
"run_command"
}
fn description() -> &'static str {
"Run an allowed command inside the working directory and return its output."
}
async fn call(self, ctx: Context) -> Result<Self::Output, ToolError> {
ctx.check_command(&self.command)?;
let output = Command::new(&self.command)
.args(&self.args)
.current_dir(ctx.working_dir())
.output()
.await?;
Ok(RunCommandOutput {
command: self.command,
args: self.args,
exit_code: output.status.code(),
stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::context::FlowConf;
fn ctx(dir: &std::path::Path, allowed: &[&str]) -> Context {
Context::new(FlowConf {
working_dir: Some(dir.to_path_buf()),
..Default::default()
})
.with_commands(allowed.iter().map(|s| s.to_string()).collect())
}
#[tokio::test]
async fn run_command_captures_stdout() {
let dir = tempfile::tempdir().unwrap();
let out = RunCommand {
command: "echo".into(),
args: vec!["hello".into()],
}
.call(ctx(dir.path(), &["echo"]))
.await
.unwrap();
assert!(out.stdout.contains("hello"));
assert_eq!(out.exit_code, Some(0));
}
#[tokio::test]
async fn run_command_uses_working_dir() {
let dir = tempfile::tempdir().unwrap();
let canonical = dir.path().canonicalize().unwrap();
let out = RunCommand {
command: "pwd".into(),
args: vec![],
}
.call(ctx(dir.path(), &["pwd"]))
.await
.unwrap();
let stdout = out.stdout.trim().to_owned();
let stdout_canon = std::path::PathBuf::from(&stdout)
.canonicalize()
.unwrap_or(std::path::PathBuf::from(&stdout));
assert_eq!(stdout_canon, canonical);
}
#[tokio::test]
async fn run_command_rejects_forbidden_command() {
let dir = tempfile::tempdir().unwrap();
let err = RunCommand {
command: "rm".into(),
args: vec!["-rf".into(), "/".into()],
}
.call(ctx(dir.path(), &["echo"]))
.await
.unwrap_err();
assert!(matches!(err, ToolError::ForbiddenCommand(c) if c == "rm"));
}
#[tokio::test]
async fn run_command_empty_allowlist_rejects_all() {
let dir = tempfile::tempdir().unwrap();
let err = RunCommand {
command: "echo".into(),
args: vec![],
}
.call(ctx(dir.path(), &[]))
.await
.unwrap_err();
assert!(matches!(err, ToolError::ForbiddenCommand(_)));
}
#[tokio::test]
async fn run_command_captures_nonzero_exit_code() {
let dir = tempfile::tempdir().unwrap();
let out = RunCommand {
command: "false".into(),
args: vec![],
}
.call(ctx(dir.path(), &["false"]))
.await
.unwrap();
assert_ne!(out.exit_code, Some(0));
}
}