phi_core/tools/bash.rs
1//! Bash tool — execute shell commands with timeout and output capture.
2/*
3ARCHITECTURE: BashTool — the agent's most powerful (and most dangerous) capability
4
5BashTool lets the agent run arbitrary shell commands. It's the tool that makes
6an agent a "coding agent" rather than a "chat agent." Combined with file tools,
7the agent can read code, run tests, install packages, and check system state.
8
9Safety layers:
10 1. `deny_patterns` — blocklist of dangerous command substrings; checked before execution
11 2. `confirm_fn` — optional callback that asks the user to approve a command
12 3. `timeout` — kills commands that run too long (prevents runaway processes)
13 4. `max_output_bytes` — truncates huge outputs (prevents OOM from `cat /dev/urandom`)
14 5. CancellationToken — user can interrupt a running command
15
16Design decision: return output even on non-zero exit codes.
17 The agent loop always gets stdout/stderr back even if the command failed.
18 This is crucial for self-correction: the agent sees "make: command not found"
19 and can decide to install `make` or use an alternative. If we returned an error,
20 the agent would have no information about what went wrong.
21
22RUST QUIRK: `tokio::process::Command` — async subprocess execution
23 `tokio::process::Command` is the async version of `std::process::Command`.
24 `cmd.output().await` runs the command and collects all output asynchronously.
25 Unlike `std::process::Command::output()` (blocks the OS thread), the tokio
26 version yields back to the runtime while waiting, allowing other tasks to run.
27*/
28
29use crate::types::*;
30
31/// Type alias for command confirmation callback.
32/*
33RUST QUIRK: `type ConfirmFn = Box<dyn Fn(&str) -> bool + Send + Sync>;`
34
35`type` creates a type alias — a shorthand name for a complex type.
36`Box<dyn Fn(&str) -> bool + Send + Sync>` means:
37 - A heap-allocated function (closure or fn pointer)
38 - That takes a `&str` (the command being run)
39 - Returns `bool` (true = allow, false = deny)
40 - Is `Send + Sync` so it can be called from any thread
41
42Why `Box<dyn Fn>`? Closures that capture variables are all different types,
43so we can't use a generic `<F: Fn>` in the struct field. We erase the type
44into a trait object instead.
45Python analogy: `ConfirmFn = Callable[[str], bool]`
46*/
47pub type ConfirmFn = Box<dyn Fn(&str) -> bool + Send + Sync>;
48use async_trait::async_trait;
49use std::time::Duration;
50use tokio::process::Command;
51
52/// Execute shell commands. Captures stdout + stderr.
53pub struct BashTool {
54 /// Working directory for commands (None = inherit from current process)
55 pub cwd: Option<String>,
56 /// Max execution time per command (default: 120s)
57 pub timeout: Duration,
58 /// Max output bytes to capture (prevents OOM on huge outputs, default: 256KB)
59 pub max_output_bytes: usize,
60 /// Commands/patterns that are always blocked (e.g., "rm -rf /")
61 pub deny_patterns: Vec<String>,
62 /// Optional callback for confirming dangerous commands (None = auto-allow)
63 pub confirm_fn: Option<ConfirmFn>,
64}
65
66impl Default for BashTool {
67 fn default() -> Self {
68 Self {
69 cwd: None,
70 timeout: Duration::from_secs(120),
71 max_output_bytes: 256 * 1024, // 256KB
72 deny_patterns: vec![
73 "rm -rf /".into(),
74 "rm -rf /*".into(),
75 "mkfs".into(),
76 "dd if=".into(),
77 ":(){:|:&};:".into(), // fork bomb
78 ],
79 confirm_fn: None,
80 }
81 }
82}
83
84impl BashTool {
85 pub fn new() -> Self {
86 Self::default()
87 }
88
89 pub fn with_cwd(mut self, cwd: impl Into<String>) -> Self {
90 self.cwd = Some(cwd.into());
91 self
92 }
93
94 pub fn with_timeout(mut self, timeout: Duration) -> Self {
95 self.timeout = timeout;
96 self
97 }
98
99 pub fn with_deny_patterns(mut self, patterns: Vec<String>) -> Self {
100 self.deny_patterns = patterns;
101 self
102 }
103
104 /*
105 RUST QUIRK: `impl Fn(&str) -> bool + Send + Sync + 'static` — accepting closures generically
106
107 This method accepts ANY callable that matches the signature `fn(&str) -> bool`.
108 `impl Fn(...)` means "some type that implements the Fn trait" — the compiler
109 generates a monomorphized version for each concrete closure type passed in.
110 This is more efficient than `Box<dyn Fn>` (no heap allocation at call site).
111
112 `+ Send + Sync + 'static` — required because we then `Box::new(f)` it and store it.
113 The stored `Box<dyn Fn>` needs to be `Send + Sync + 'static` to be held in the struct
114 (which may be shared across threads via `Arc`).
115 `'static` means the closure must not capture any borrowed references.
116
117 Python analogy: accepting a callable with `def with_confirm(self, f: Callable[[str], bool]):`
118 */
119 pub fn with_confirm(mut self, f: impl Fn(&str) -> bool + Send + Sync + 'static) -> Self {
120 self.confirm_fn = Some(Box::new(f));
121 self
122 }
123}
124
125#[async_trait]
126impl AgentTool for BashTool {
127 fn name(&self) -> &str {
128 "bash"
129 }
130
131 fn label(&self) -> &str {
132 "Execute Command"
133 }
134
135 fn description(&self) -> &str {
136 "Execute a bash command and return stdout/stderr. Use for running scripts, installing packages, checking system state, etc."
137 }
138
139 fn parameters_schema(&self) -> serde_json::Value {
140 serde_json::json!({
141 "type": "object",
142 "properties": {
143 "command": {
144 "type": "string",
145 "description": "The bash command to execute"
146 }
147 },
148 "required": ["command"]
149 })
150 }
151
152 async fn execute(
153 &self,
154 params: serde_json::Value, // LLM INPUT — expects `{"command": "..."}` — the shell command to run
155 ctx: ToolContext, // SYSTEM ENV — ctx.cancel used in tokio::select! to race cancel|timeout|execution
156 ) -> Result<ToolResult, ToolError> {
157 let cancel = ctx.cancel;
158 let command = params["command"]
159 .as_str()
160 .ok_or_else(|| ToolError::InvalidArgs("missing 'command' parameter".into()))?;
161
162 // Check deny patterns
163 for pattern in &self.deny_patterns {
164 if command.contains(pattern.as_str()) {
165 return Err(ToolError::Failed(format!(
166 "Command blocked by safety policy: contains '{}'. This pattern is denied for safety.",
167 pattern
168 )));
169 }
170 }
171
172 // Check confirmation callback
173 if let Some(ref confirm) = self.confirm_fn {
174 if !confirm(command) {
175 return Err(ToolError::Failed(
176 "Command was not confirmed by the user.".into(),
177 ));
178 }
179 }
180
181 /*
182 RUST QUIRK: `tokio::process::Command` — building an async subprocess
183
184 `Command::new("bash")` creates a command builder (not yet executed).
185 `.arg("-c")` adds the "-c" flag (run the next argument as a shell script).
186 `.arg(command)` adds the actual command string.
187 `.stdout(Stdio::piped())` — capture stdout instead of inheriting from parent.
188 `.stderr(Stdio::piped())` — capture stderr too.
189 `.current_dir(cwd)` — set the working directory.
190
191 None of these actually run the process. `.output().await` launches it.
192 */
193 let mut cmd = Command::new("bash");
194 cmd.arg("-c").arg(command);
195
196 if let Some(ref cwd) = self.cwd {
197 cmd.current_dir(cwd);
198 }
199
200 // Capture both stdout and stderr (not inherit from parent process)
201 cmd.stdout(std::process::Stdio::piped());
202 cmd.stderr(std::process::Stdio::piped());
203
204 let timeout = self.timeout;
205 let max_bytes = self.max_output_bytes;
206
207 /*
208 ARCHITECTURE: Three-way race: cancellation | timeout | execution
209
210 `tokio::select!` races three futures simultaneously:
211 1. `cancel.cancelled()` — user interrupted (Ctrl-C or agent stopped)
212 2. `tokio::time::sleep(timeout)` — command exceeded time limit
213 3. `cmd.output()` — command completed (success or failure)
214
215 The first branch to complete wins; the others are dropped (cancelled).
216 This is the idiomatic tokio pattern for "run this with a deadline."
217
218 RUST QUIRK: `result.map_err(|e| ToolError::Failed(...))?`
219 `cmd.output()` returns `Result<Output, io::Error>`.
220 `.map_err(...)` converts `io::Error` → `ToolError::Failed(String)`.
221 `?` propagates the error out of the whole `execute()` function if present.
222 */
223 let result = tokio::select! {
224 _ = cancel.cancelled() => {
225 return Err(ToolError::Cancelled);
226 }
227 _ = tokio::time::sleep(timeout) => {
228 return Err(ToolError::Failed(format!(
229 "Command timed out after {}s",
230 timeout.as_secs()
231 )));
232 }
233 result = cmd.output() => {
234 result.map_err(|e| ToolError::Failed(format!("Failed to execute: {}", e)))?
235 }
236 };
237
238 /*
239 RUST QUIRK: `String::from_utf8_lossy(&bytes).to_string()`
240 `result.stdout` is `Vec<u8>` — raw bytes.
241 `from_utf8_lossy` converts bytes to `Cow<str>`:
242 - If valid UTF-8 → `Cow::Borrowed(&str)` (no allocation)
243 - If invalid UTF-8 → `Cow::Owned(String)` with `?` replacing bad bytes
244 `.to_string()` converts the `Cow<str>` to an owned `String` in all cases.
245 This handles programs that output non-UTF-8 (binary data, legacy encodings)
246 gracefully — we show them with replacement characters rather than panicking.
247 */
248 let mut stdout = String::from_utf8_lossy(&result.stdout).to_string();
249 let mut stderr = String::from_utf8_lossy(&result.stderr).to_string();
250
251 // Truncate if too large
252 if stdout.len() > max_bytes {
253 stdout.truncate(max_bytes);
254 stdout.push_str("\n... (output truncated)");
255 }
256 if stderr.len() > max_bytes {
257 stderr.truncate(max_bytes);
258 stderr.push_str("\n... (output truncated)");
259 }
260
261 let exit_code = result.status.code().unwrap_or(-1);
262
263 let output = if stderr.is_empty() {
264 format!("Exit code: {}\n{}", exit_code, stdout)
265 } else {
266 format!(
267 "Exit code: {}\nSTDOUT:\n{}\nSTDERR:\n{}",
268 exit_code, stdout, stderr
269 )
270 };
271
272 // Return output even on failure — LLMs need error output to self-correct
273 Ok(ToolResult {
274 content: vec![Content::Text { text: output }],
275 details: serde_json::json!({ "exit_code": exit_code, "success": exit_code == 0 }),
276 child_loop_id: None,
277 })
278 }
279}