telos_agent/tools/builtin/
shell.rs1use async_trait::async_trait;
8use serde_json::{Value, json};
9use tokio::io::AsyncReadExt;
10use tokio::process::Command;
11use tokio::time::{Duration, timeout};
12
13use crate::error::AgentError;
14use crate::tools::api::{PermissionDecision, Tool, ToolContext, ToolDefinition, ToolOutput};
15
16use super::{optional_usize_any, required_string};
17use crate::tools::command_security::bash::{CommandSafety, analyze as analyze_command_safety};
18
19pub struct ShellTool;
21
22#[async_trait]
23impl Tool for ShellTool {
24 fn definition(&self) -> ToolDefinition {
25 ToolDefinition {
26 name: "Bash".into(),
27 description: "Run a bash command in the current working directory. Prefer Read/Edit/Write/Glob/Grep for file operations.".into(),
28 input_schema: json!({
29 "type": "object",
30 "properties": {
31 "command": { "type": "string" },
32 "description": { "type": "string" },
33 "timeout_ms": { "type": "integer", "description": "Maximum runtime in milliseconds. Defaults to 120000." }
34 },
35 "required": ["command"]
36 }),
37 }
38 }
39
40 fn prompt_text(&self) -> Option<&'static str> {
41 Some(
42 "Use Bash for shell commands, build/test runners, and git operations. \
43Prefer Read, Edit, Write, Glob, Grep for file operations. \
44Provide a short `description` of the command's intent. Avoid superuser commands unless instructed.",
45 )
46 }
47
48 async fn validate(&self, arguments: &Value, _context: &ToolContext) -> Result<(), AgentError> {
49 required_string(arguments, "command").map(|_| ())
50 }
51
52 async fn check_permission(
53 &self,
54 arguments: &Value,
55 _context: &ToolContext,
56 ) -> Result<PermissionDecision, AgentError> {
57 let command = required_string(arguments, "command")?;
58 match analyze_command_safety(command) {
63 CommandSafety::Safe => Ok(PermissionDecision::Allow),
64 CommandSafety::NeedsReview { reason } => Ok(PermissionDecision::Ask {
65 reason: format!("shell command needs review: {reason}"),
66 }),
67 }
68 }
69
70 async fn invoke(
71 &self,
72 arguments: Value,
73 context: ToolContext,
74 ) -> Result<ToolOutput, AgentError> {
75 let command = required_string(&arguments, "command")?;
76 let timeout_ms =
77 optional_usize_any(&arguments, &["timeout_ms"]).unwrap_or(120_000).max(1) as u64;
78 if let Some(tx) = &context.progress {
79 let _ = tx.send(crate::tools::api::ToolProgress {
80 tool_call_id: None,
81 message: format!("running command with {timeout_ms}ms timeout"),
82 data: Some(json!({ "command": command, "timeout_ms": timeout_ms })),
83 });
84 }
85 let mut child = Command::new("bash");
86 child
87 .arg("-c")
88 .arg(command)
89 .current_dir(&context.cwd)
90 .env_clear()
94 .envs(context.env.iter());
95 hide_console_window(&mut child);
96 #[cfg(unix)]
97 {
98 child.process_group(0);
99 }
100 child.kill_on_drop(true);
101 let output = timeout(Duration::from_millis(timeout_ms), run_shell_child(child))
102 .await
103 .map_err(|_| AgentError::ToolExecution {
104 tool: "Bash".into(),
105 message: format!("Command timed out after {timeout_ms}ms"),
106 })?
107 .map_err(|err| AgentError::ToolExecution {
108 tool: "Bash".into(),
109 message: err.to_string(),
110 })?;
111
112 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
113 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
114 if !output.status.success() {
115 return Err(AgentError::ToolExecution {
116 tool: "Bash".into(),
117 message: format!(
118 "Command failed with exit code {:?}\nstdout:\n{}\nstderr:\n{}",
119 output.status.code(),
120 trim_large_output(&stdout),
121 trim_large_output(&stderr)
122 ),
123 });
124 }
125 Ok(ToolOutput::json(json!({
126 "status": output.status.code(),
127 "success": output.status.success(),
128 "stdout": trim_large_output(&stdout),
129 "stderr": trim_large_output(&stderr),
130 })))
131 }
132}
133
134async fn run_shell_child(mut command: Command) -> std::io::Result<std::process::Output> {
135 command.stdout(std::process::Stdio::piped()).stderr(std::process::Stdio::piped());
136 let mut child = command.spawn()?;
137 let mut guard = ProcessCleanupGuard::new(&child);
138 let mut stdout = child.stdout.take().expect("stdout was piped");
139 let mut stderr = child.stderr.take().expect("stderr was piped");
140
141 let stdout_task = tokio::spawn(async move {
142 let mut buf = Vec::new();
143 stdout.read_to_end(&mut buf).await.map(|_| buf)
144 });
145 let stderr_task = tokio::spawn(async move {
146 let mut buf = Vec::new();
147 stderr.read_to_end(&mut buf).await.map(|_| buf)
148 });
149
150 let status = child.wait().await?;
151 guard.disarm();
152 let stdout = stdout_task.await.map_err(std::io::Error::other)??;
153 let stderr = stderr_task.await.map_err(std::io::Error::other)??;
154
155 Ok(std::process::Output { status, stdout, stderr })
156}
157
158#[cfg_attr(not(windows), allow(unused_variables))]
159fn hide_console_window(command: &mut Command) {
160 #[cfg(windows)]
161 {
162 const CREATE_NO_WINDOW: u32 = 0x0800_0000;
163 command.creation_flags(CREATE_NO_WINDOW);
164 }
165}
166
167struct ProcessCleanupGuard {
168 #[cfg(unix)]
169 process_group_id: Option<i32>,
170}
171
172impl ProcessCleanupGuard {
173 #[cfg(unix)]
174 fn new(child: &tokio::process::Child) -> Self {
175 Self { process_group_id: child.id().map(|pid| pid as i32) }
176 }
177
178 #[cfg(not(unix))]
179 fn new(_child: &tokio::process::Child) -> Self {
180 Self {}
181 }
182
183 fn disarm(&mut self) {
184 #[cfg(unix)]
185 {
186 self.process_group_id = None;
187 }
188 }
189}
190
191impl Drop for ProcessCleanupGuard {
192 fn drop(&mut self) {
193 #[cfg(unix)]
194 if let Some(pgid) = self.process_group_id {
195 unsafe {
196 libc::kill(-pgid, libc::SIGKILL);
197 }
198 }
199 }
200}
201
202fn trim_large_output(output: &str) -> String {
203 const MAX_CHARS: usize = 20_000;
204 if output.chars().count() <= MAX_CHARS {
205 return output.to_string();
206 }
207 let preview = output.chars().take(MAX_CHARS).collect::<String>();
208 format!("{preview}\n<truncated output after {MAX_CHARS} chars>")
209}
210
211#[cfg(all(test, unix))]
212mod tests {
213 use super::*;
214 use serde_json::json;
215 use std::collections::HashMap;
216
217 fn ctx(cwd: std::path::PathBuf, env: HashMap<String, String>) -> ToolContext {
218 ToolContext {
219 session_id: "test".into(),
220 turn_id: 1,
221 tool_call_id: None,
222 cwd,
223 env,
224 messages: std::sync::Arc::new(vec![]),
225 progress: None,
226 read_file_state: std::sync::Arc::new(tokio::sync::Mutex::new(HashMap::new())),
227 timeout: None,
228 max_file_read_bytes: usize::MAX,
229 }
230 }
231
232 #[tokio::test]
233 async fn safe_command_runs_with_clean_environment() {
234 unsafe { std::env::set_var("TINY_AGENT_SECRET", "leaked") };
238 let tool = ShellTool;
239 let mut env = HashMap::new();
240 env.insert("PATH".into(), "/usr/local/bin:/usr/bin:/bin".into());
241 let output = tool
242 .invoke(json!({ "command": "echo $TINY_AGENT_SECRET" }), ctx(std::env::temp_dir(), env))
243 .await
244 .unwrap();
245 let stdout = output.content["stdout"].as_str().unwrap();
246 assert_eq!(stdout.trim(), "");
247 unsafe { std::env::remove_var("TINY_AGENT_SECRET") };
248 }
249
250 #[tokio::test]
251 async fn configured_env_is_passed_through() {
252 let tool = ShellTool;
253 let mut env = HashMap::new();
254 env.insert("PATH".into(), "/usr/local/bin:/usr/bin:/bin".into());
255 env.insert("MY_VAR".into(), "present".into());
256 let output = tool
257 .invoke(json!({ "command": "echo $MY_VAR" }), ctx(std::env::temp_dir(), env))
258 .await
259 .unwrap();
260 let stdout = output.content["stdout"].as_str().unwrap();
261 assert_eq!(stdout.trim(), "present");
262 }
263}