Add run_command tool to execute shell commands securely
Seto Elkahfi committed
Apr 24, 2026 at 01:51 UTC
25ececeab2583adfba540595252059feeb194fdc
2 files changed
+197
-4
src/main.rs
+3
-3
index 1fe106d..acec97c 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -67,9 +67,9 @@ Keep answers short. Write idiomatic code. \
Fix root causes, not symptoms.
You have access to tools that let you read files, list directories, search \
-code, create new files, edit existing files, and delete files. Use them \
-proactively to understand the codebase before answering questions or writing \
-code. Always ground your answers in the actual code.
+code, create new files, edit existing files, delete files, and run shell \
+commands. Use them proactively — read the code before answering, run builds \
+and tests after making changes. Always ground your answers in the actual code.
Be direct and brief. Write clean, idiomatic code. When debugging, go for the \
root cause, not the symptom. Correct beats clever.";
src/tools.rs
+194
-1
index 33cd8a0..9626907 100644
--- a/src/tools.rs
+++ b/src/tools.rs
@@ -17,11 +17,16 @@
//! - `create_file` — create a new file (fails if it already exists)
//! - `edit_file` — replace an exact old-text span with new text in an existing file
//! - `delete_file` — delete a file or empty directory at the given path
+//!
+//! # Shell Tools
+//!
+//! - `run_command` — run a shell command and return its combined stdout/stderr output
use regex::Regex;
use serde_json::{Value, json};
use std::fs;
use std::path::Path;
+use std::process::Command;
/// Maximum characters returned from `read_file` before truncation.
const READ_FILE_CHAR_LIMIT: usize = 10_000;
@@ -165,6 +170,30 @@ pub fn all_tools() -> Vec<AgentTool> {
"additionalProperties": false
}),
},
+ AgentTool {
+ name: "run_command",
+ description: "Run a shell command and return its combined stdout and stderr output. \
+ The command runs in the given working directory (defaults to \".\"). \
+ Use this for build tools (cargo, npm, make), version control (git), \
+ package managers, linters, test runners, and other CLI tasks. \
+ Commands that run indefinitely (servers, watchers) will be killed \
+ after 120 seconds.",
+ parameters_schema: json!({
+ "type": "object",
+ "properties": {
+ "command": {
+ "type": "string",
+ "description": "The shell command to execute (e.g. \"cargo update\", \"git status\")."
+ },
+ "cwd": {
+ "type": "string",
+ "description": "Working directory for the command. Defaults to \".\" (current directory)."
+ }
+ },
+ "required": ["command"],
+ "additionalProperties": false
+ }),
+ },
]
}
@@ -182,6 +211,7 @@ pub fn execute_tool(name: &str, arguments: &str) -> String {
"create_file" => exec_create_file(arguments),
"edit_file" => exec_edit_file(arguments),
"delete_file" => exec_delete_file(arguments),
+ "run_command" => exec_run_command(arguments),
_ => format!("Unknown tool: {name}"),
}
}
@@ -557,6 +587,114 @@ fn exec_delete_file(arguments: &str) -> String {
}
}
+// ── run_command ──────────────────────────────────────────────────────────────
+
+/// Maximum time a command is allowed to run before being killed.
+const COMMAND_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120);
+
+/// Maximum bytes of combined output returned from a command.
+const COMMAND_OUTPUT_LIMIT: usize = 50_000;
+
+/// Run a shell command and return its combined stdout + stderr output.
+///
+/// The command is executed via `sh -c` (Unix) or `cmd /C` (Windows) so shell
+/// features like pipes, redirects, and chaining work out of the box.
+///
+/// Long-running commands are killed after [`COMMAND_TIMEOUT`] seconds.
+fn exec_run_command(arguments: &str) -> String {
+ let args: Value = match serde_json::from_str(arguments) {
+ Ok(v) => v,
+ Err(err) => return format!("Error: failed to parse arguments: {err}"),
+ };
+
+ let command_str = match args.get("command").and_then(Value::as_str) {
+ Some(c) => c,
+ None => return "Error: missing required parameter \"command\"".to_string(),
+ };
+
+ let cwd = args.get("cwd").and_then(Value::as_str).unwrap_or(".");
+
+ let cwd_path = Path::new(cwd);
+ if !cwd_path.exists() {
+ return format!("Error: working directory does not exist: {cwd}");
+ }
+
+ log::info!("run_command: `{command_str}` in `{cwd}`");
+
+ #[cfg(unix)]
+ let mut child = match Command::new("sh")
+ .arg("-c")
+ .arg(command_str)
+ .current_dir(cwd_path)
+ .stdout(std::process::Stdio::piped())
+ .stderr(std::process::Stdio::piped())
+ .spawn()
+ {
+ Ok(c) => c,
+ Err(err) => return format!("Error: failed to spawn command: {err}"),
+ };
+
+ #[cfg(windows)]
+ let mut child = match Command::new("cmd")
+ .arg("/C")
+ .arg(command_str)
+ .current_dir(cwd_path)
+ .stdout(std::process::Stdio::piped())
+ .stderr(std::process::Stdio::piped())
+ .spawn()
+ {
+ Ok(c) => c,
+ Err(err) => return format!("Error: failed to spawn command: {err}"),
+ };
+
+ // Wait with a timeout.
+ let start = std::time::Instant::now();
+ loop {
+ match child.try_wait() {
+ Ok(Some(_status)) => break,
+ Ok(None) => {
+ if start.elapsed() >= COMMAND_TIMEOUT {
+ let _ = child.kill();
+ return format!(
+ "Error: command timed out after {} seconds and was killed.",
+ COMMAND_TIMEOUT.as_secs()
+ );
+ }
+ std::thread::sleep(std::time::Duration::from_millis(100));
+ }
+ Err(err) => return format!("Error: failed to wait on command: {err}"),
+ }
+ }
+
+ let output = match child.wait_with_output() {
+ Ok(o) => o,
+ Err(err) => return format!("Error: failed to read command output: {err}"),
+ };
+
+ let exit_code = output.status.code().unwrap_or(-1);
+ let mut combined = String::new();
+ combined.push_str(&String::from_utf8_lossy(&output.stdout));
+ combined.push_str(&String::from_utf8_lossy(&output.stderr));
+
+ // Truncate if output is huge.
+ let truncated = if combined.len() > COMMAND_OUTPUT_LIMIT {
+ let truncated_str = &combined[..COMMAND_OUTPUT_LIMIT];
+ format!("{truncated_str}\n\n… (output truncated at {COMMAND_OUTPUT_LIMIT} bytes)")
+ } else {
+ combined
+ };
+
+ if output.status.success() {
+ if truncated.is_empty() {
+ format!("Command succeeded (exit code {exit_code}) with no output.")
+ } else {
+ format!("Exit code {exit_code}:\n{truncated}")
+ }
+ } else {
+ format!("Command failed (exit code {exit_code}):\n{truncated}")
+ }
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -671,13 +809,14 @@ mod tests {
#[test]
fn test_all_tools_count() {
let tools = all_tools();
- assert_eq!(tools.len(), 6);
+ assert_eq!(tools.len(), 7);
assert_eq!(tools[0].name, "read_file");
assert_eq!(tools[1].name, "list_directory");
assert_eq!(tools[2].name, "search_files");
assert_eq!(tools[3].name, "create_file");
assert_eq!(tools[4].name, "edit_file");
assert_eq!(tools[5].name, "delete_file");
+ assert_eq!(tools[6].name, "run_command");
}
#[test]
@@ -899,4 +1038,58 @@ mod tests {
let _ = fs::remove_dir_all(&dir);
}
+
+ // ── run_command tests ────────────────────────────────────────────────
+
+ #[test]
+ fn test_run_command_missing_command() {
+ let result = exec_run_command("{}");
+ assert!(
+ result.contains("missing required parameter"),
+ "got: {result}"
+ );
+ }
+
+ #[test]
+ fn test_run_command_success() {
+ let result = exec_run_command(r#"{"command": "echo hello"}"#);
+ assert!(result.contains("hello"), "got: {result}");
+ assert!(result.contains("Exit code 0"), "got: {result}");
+ }
+
+ #[test]
+ fn test_run_command_failure() {
+ let result = exec_run_command(r#"{"command": "false"}"#);
+ assert!(result.contains("failed"), "got: {result}");
+ }
+
+ #[test]
+ fn test_run_command_with_cwd() {
+ let dir = std::env::temp_dir().join("sigit_test_run_cmd_cwd");
+ let _ = fs::remove_dir_all(&dir);
+ fs::create_dir_all(&dir).unwrap();
+
+ let args = format!(r#"{{"command": "pwd", "cwd": "{}"}}"#, dir.display());
+ let result = exec_run_command(&args);
+ // The output should contain the temp dir path.
+ assert!(
+ result.contains(&dir.to_string_lossy().to_string()),
+ "got: {result}"
+ );
+
+ let _ = fs::remove_dir_all(&dir);
+ }
+
+ #[test]
+ fn test_run_command_bad_cwd() {
+ let result =
+ exec_run_command(r#"{"command": "echo hi", "cwd": "/tmp/sigit_no_such_dir_xyz"}"#);
+ assert!(result.contains("does not exist"), "got: {result}");
+ }
+
+ #[test]
+ fn test_run_command_captures_stderr() {
+ let result = exec_run_command(r#"{"command": "echo err >&2"}"#);
+ assert!(result.contains("err"), "got: {result}");
+ }
}