Skip to main content

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}