Skip to main content

telos_agent/tools/builtin/
shell.rs

1//! `Bash` tool — run an arbitrary shell command in the workspace cwd.
2//!
3//! Permission policy: commands that look obviously read-only (see
4//! [`is_obviously_read_only_command`]) are auto-approved; everything else
5//! requires host approval via [`PermissionDecision::Ask`].
6
7use 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
19/// Built-in shell tool. Spawns `bash -c <command>` inside the workspace.
20pub 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        // Shell commands are extremely powerful and can read arbitrary files or
59        // exfiltrate data even when they use a "read-only" looking basename.
60        // Require explicit approval by default; callers can opt into auto-approval
61        // via the global permission engine.
62        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            // Start with a clean environment and only add what the caller
91            // explicitly configured. Inheriting the parent process environment
92            // would leak secrets (API keys, tokens) to arbitrary tool calls.
93            .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        // Even if the parent process has SECRET set, the shell command should
235        // not see it because ShellTool clears the environment before adding
236        // only the configured variables.
237        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}