@hej / sigit / commits / 25ecece

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
@@ -67,9 +67,9 @@ Keep answers short. Write idiomatic code. \
67 Fix root causes, not symptoms.
68
69 You have access to tools that let you read files, list directories, search \
70 -code, create new files, edit existing files, and delete files. Use them \
71 -proactively to understand the codebase before answering questions or writing \
72 -code. Always ground your answers in the actual code.
70 +code, create new files, edit existing files, delete files, and run shell \
71 +commands. Use them proactively — read the code before answering, run builds \
72 +and tests after making changes. Always ground your answers in the actual code.
73
74 Be direct and brief. Write clean, idiomatic code. When debugging, go for the \
75 root cause, not the symptom. Correct beats clever.";
src/tools.rs
+194 -1
@@ -17,11 +17,16 @@
17 //! - `create_file` — create a new file (fails if it already exists)
18 //! - `edit_file` — replace an exact old-text span with new text in an existing file
19 //! - `delete_file` — delete a file or empty directory at the given path
20 +//!
21 +//! # Shell Tools
22 +//!
23 +//! - `run_command` — run a shell command and return its combined stdout/stderr output
24
25 use regex::Regex;
26 use serde_json::{Value, json};
27 use std::fs;
28 use std::path::Path;
29 +use std::process::Command;
30
31 /// Maximum characters returned from `read_file` before truncation.
32 const READ_FILE_CHAR_LIMIT: usize = 10_000;
@@ -165,6 +170,30 @@ pub fn all_tools() -> Vec<AgentTool> {
170 "additionalProperties": false
171 }),
172 },
173 + AgentTool {
174 + name: "run_command",
175 + description: "Run a shell command and return its combined stdout and stderr output. \
176 + The command runs in the given working directory (defaults to \".\"). \
177 + Use this for build tools (cargo, npm, make), version control (git), \
178 + package managers, linters, test runners, and other CLI tasks. \
179 + Commands that run indefinitely (servers, watchers) will be killed \
180 + after 120 seconds.",
181 + parameters_schema: json!({
182 + "type": "object",
183 + "properties": {
184 + "command": {
185 + "type": "string",
186 + "description": "The shell command to execute (e.g. \"cargo update\", \"git status\")."
187 + },
188 + "cwd": {
189 + "type": "string",
190 + "description": "Working directory for the command. Defaults to \".\" (current directory)."
191 + }
192 + },
193 + "required": ["command"],
194 + "additionalProperties": false
195 + }),
196 + },
197 ]
198 }
199
@@ -182,6 +211,7 @@ pub fn execute_tool(name: &str, arguments: &str) -> String {
211 "create_file" => exec_create_file(arguments),
212 "edit_file" => exec_edit_file(arguments),
213 "delete_file" => exec_delete_file(arguments),
214 + "run_command" => exec_run_command(arguments),
215 _ => format!("Unknown tool: {name}"),
216 }
217 }
@@ -557,6 +587,114 @@ fn exec_delete_file(arguments: &str) -> String {
587 }
588 }
589
590 +// ── run_command ──────────────────────────────────────────────────────────────
591 +
592 +/// Maximum time a command is allowed to run before being killed.
593 +const COMMAND_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120);
594 +
595 +/// Maximum bytes of combined output returned from a command.
596 +const COMMAND_OUTPUT_LIMIT: usize = 50_000;
597 +
598 +/// Run a shell command and return its combined stdout + stderr output.
599 +///
600 +/// The command is executed via `sh -c` (Unix) or `cmd /C` (Windows) so shell
601 +/// features like pipes, redirects, and chaining work out of the box.
602 +///
603 +/// Long-running commands are killed after [`COMMAND_TIMEOUT`] seconds.
604 +fn exec_run_command(arguments: &str) -> String {
605 + let args: Value = match serde_json::from_str(arguments) {
606 + Ok(v) => v,
607 + Err(err) => return format!("Error: failed to parse arguments: {err}"),
608 + };
609 +
610 + let command_str = match args.get("command").and_then(Value::as_str) {
611 + Some(c) => c,
612 + None => return "Error: missing required parameter \"command\"".to_string(),
613 + };
614 +
615 + let cwd = args.get("cwd").and_then(Value::as_str).unwrap_or(".");
616 +
617 + let cwd_path = Path::new(cwd);
618 + if !cwd_path.exists() {
619 + return format!("Error: working directory does not exist: {cwd}");
620 + }
621 +
622 + log::info!("run_command: `{command_str}` in `{cwd}`");
623 +
624 + #[cfg(unix)]
625 + let mut child = match Command::new("sh")
626 + .arg("-c")
627 + .arg(command_str)
628 + .current_dir(cwd_path)
629 + .stdout(std::process::Stdio::piped())
630 + .stderr(std::process::Stdio::piped())
631 + .spawn()
632 + {
633 + Ok(c) => c,
634 + Err(err) => return format!("Error: failed to spawn command: {err}"),
635 + };
636 +
637 + #[cfg(windows)]
638 + let mut child = match Command::new("cmd")
639 + .arg("/C")
640 + .arg(command_str)
641 + .current_dir(cwd_path)
642 + .stdout(std::process::Stdio::piped())
643 + .stderr(std::process::Stdio::piped())
644 + .spawn()
645 + {
646 + Ok(c) => c,
647 + Err(err) => return format!("Error: failed to spawn command: {err}"),
648 + };
649 +
650 + // Wait with a timeout.
651 + let start = std::time::Instant::now();
652 + loop {
653 + match child.try_wait() {
654 + Ok(Some(_status)) => break,
655 + Ok(None) => {
656 + if start.elapsed() >= COMMAND_TIMEOUT {
657 + let _ = child.kill();
658 + return format!(
659 + "Error: command timed out after {} seconds and was killed.",
660 + COMMAND_TIMEOUT.as_secs()
661 + );
662 + }
663 + std::thread::sleep(std::time::Duration::from_millis(100));
664 + }
665 + Err(err) => return format!("Error: failed to wait on command: {err}"),
666 + }
667 + }
668 +
669 + let output = match child.wait_with_output() {
670 + Ok(o) => o,
671 + Err(err) => return format!("Error: failed to read command output: {err}"),
672 + };
673 +
674 + let exit_code = output.status.code().unwrap_or(-1);
675 + let mut combined = String::new();
676 + combined.push_str(&String::from_utf8_lossy(&output.stdout));
677 + combined.push_str(&String::from_utf8_lossy(&output.stderr));
678 +
679 + // Truncate if output is huge.
680 + let truncated = if combined.len() > COMMAND_OUTPUT_LIMIT {
681 + let truncated_str = &combined[..COMMAND_OUTPUT_LIMIT];
682 + format!("{truncated_str}\n\n… (output truncated at {COMMAND_OUTPUT_LIMIT} bytes)")
683 + } else {
684 + combined
685 + };
686 +
687 + if output.status.success() {
688 + if truncated.is_empty() {
689 + format!("Command succeeded (exit code {exit_code}) with no output.")
690 + } else {
691 + format!("Exit code {exit_code}:\n{truncated}")
692 + }
693 + } else {
694 + format!("Command failed (exit code {exit_code}):\n{truncated}")
695 + }
696 +}
697 +
698 #[cfg(test)]
699 mod tests {
700 use super::*;
@@ -671,13 +809,14 @@ mod tests {
809 #[test]
810 fn test_all_tools_count() {
811 let tools = all_tools();
674 - assert_eq!(tools.len(), 6);
812 + assert_eq!(tools.len(), 7);
813 assert_eq!(tools[0].name, "read_file");
814 assert_eq!(tools[1].name, "list_directory");
815 assert_eq!(tools[2].name, "search_files");
816 assert_eq!(tools[3].name, "create_file");
817 assert_eq!(tools[4].name, "edit_file");
818 assert_eq!(tools[5].name, "delete_file");
819 + assert_eq!(tools[6].name, "run_command");
820 }
821
822 #[test]
@@ -899,4 +1038,58 @@ mod tests {
1038
1039 let _ = fs::remove_dir_all(&dir);
1040 }
1041 +
1042 + // ── run_command tests ────────────────────────────────────────────────
1043 +
1044 + #[test]
1045 + fn test_run_command_missing_command() {
1046 + let result = exec_run_command("{}");
1047 + assert!(
1048 + result.contains("missing required parameter"),
1049 + "got: {result}"
1050 + );
1051 + }
1052 +
1053 + #[test]
1054 + fn test_run_command_success() {
1055 + let result = exec_run_command(r#"{"command": "echo hello"}"#);
1056 + assert!(result.contains("hello"), "got: {result}");
1057 + assert!(result.contains("Exit code 0"), "got: {result}");
1058 + }
1059 +
1060 + #[test]
1061 + fn test_run_command_failure() {
1062 + let result = exec_run_command(r#"{"command": "false"}"#);
1063 + assert!(result.contains("failed"), "got: {result}");
1064 + }
1065 +
1066 + #[test]
1067 + fn test_run_command_with_cwd() {
1068 + let dir = std::env::temp_dir().join("sigit_test_run_cmd_cwd");
1069 + let _ = fs::remove_dir_all(&dir);
1070 + fs::create_dir_all(&dir).unwrap();
1071 +
1072 + let args = format!(r#"{{"command": "pwd", "cwd": "{}"}}"#, dir.display());
1073 + let result = exec_run_command(&args);
1074 + // The output should contain the temp dir path.
1075 + assert!(
1076 + result.contains(&dir.to_string_lossy().to_string()),
1077 + "got: {result}"
1078 + );
1079 +
1080 + let _ = fs::remove_dir_all(&dir);
1081 + }
1082 +
1083 + #[test]
1084 + fn test_run_command_bad_cwd() {
1085 + let result =
1086 + exec_run_command(r#"{"command": "echo hi", "cwd": "/tmp/sigit_no_such_dir_xyz"}"#);
1087 + assert!(result.contains("does not exist"), "got: {result}");
1088 + }
1089 +
1090 + #[test]
1091 + fn test_run_command_captures_stderr() {
1092 + let result = exec_run_command(r#"{"command": "echo err >&2"}"#);
1093 + assert!(result.contains("err"), "got: {result}");
1094 + }
1095 }