Skip to main content

rhema_agent_tools/
shell.rs

1//! The built-in tool: one approval-gated shell, scoped to a workspace.
2//!
3//! [`ShellTools`] is one [`ToolResolver`], not a registry: the same spec
4//! goes to the model every step. `bash` runs its command with `bash -c`
5//! and the workspace root as working directory — reading, listing, and
6//! editing are shell work, not separate tools. The output streams are
7//! capped and the command killed after a timeout; the exit code rides on
8//! the result for the UI. Every call consults the approval hook installed
9//! for the current turn; tool resolution happens synchronously inside a
10//! turn, so a per-turn swap of that hook is race-free. No hook installed
11//! means calls are rejected — the fail-safe default.
12
13use std::path::{Path, PathBuf};
14use std::process::Stdio;
15use std::sync::atomic::Ordering;
16use std::sync::{Arc, Mutex, PoisonError};
17use std::time::Duration;
18
19use async_trait::async_trait;
20use serde_json::{Value, json};
21use tokio::io::{AsyncBufReadExt, BufReader};
22
23use rhema_core::{ToolCall, ToolContext, ToolResolver, ToolResult, ToolSpec};
24
25use crate::approval::Approval;
26
27/// Command output streams are each cut at this size — in bytes and in
28/// lines, whichever is hit first — keeping the tail: errors and final
29/// results live at the end of a command's output.
30const BASH_OUTPUT_LIMIT: usize = 50 * 1024;
31const BASH_OUTPUT_LINES: usize = 2000;
32/// A command is killed after this long.
33const BASH_TIMEOUT: Duration = Duration::from_secs(120);
34
35/// The built-in tool set: `bash`. Shared behind an `Arc` between the
36/// engine's `Agent` and the driver.
37pub struct ShellTools {
38    /// Working directory every command starts in.
39    workspace: PathBuf,
40    /// The approval hook installed for the current turn; `None` rejects
41    /// calls.
42    pending: Mutex<Option<Arc<dyn Approval>>>,
43}
44
45impl ShellTools {
46    /// The specs the built-in tool set advertises. Fixed at compile time
47    /// and independent of the instance: the system-prompt renderer names
48    /// the same tools the resolver offers, so the prompt and the API's
49    /// tools array cannot drift.
50    pub fn builtin_specs() -> Vec<ToolSpec> {
51        vec![ToolSpec::new(
52            "bash",
53            "Run a shell command (`bash -c`) with the workspace root as \
54             working directory. Reading, listing, and editing files are \
55             shell work. Output is capped and the exit status reported; a \
56             non-zero exit comes back as an error. Requires user approval.",
57            json!({
58                "type": "object",
59                "properties": {
60                    "command": {
61                        "type": "string",
62                        "description": "The shell command to run."
63                    }
64                },
65                "required": ["command"]
66            }),
67        )]
68    }
69
70    /// Tools rooted at `workspace`; the path is canonicalized, falling back
71    /// to the input if canonicalization fails (canonicalization requires
72    /// every component to exist).
73    pub fn new(workspace: impl Into<PathBuf>) -> Self {
74        Self {
75            workspace: canonicalized(workspace.into()),
76            pending: Mutex::new(None),
77        }
78    }
79
80    /// The working directory every command starts in.
81    pub fn workspace(&self) -> &Path {
82        &self.workspace
83    }
84
85    /// Installs the approval hook consulted by the tool. Call once per
86    /// turn before stepping; tool resolution cannot overlap another
87    /// turn's hook, so the swap is safe.
88    pub fn install_approval(&self, approval: Arc<dyn Approval>) {
89        *self.pending.lock().unwrap_or_else(PoisonError::into_inner) = Some(approval);
90    }
91
92    /// Removes the approval hook; calls reject until one is installed
93    /// again.
94    pub fn clear_approval(&self) {
95        *self.pending.lock().unwrap_or_else(PoisonError::into_inner) = None;
96    }
97
98    /// `None` to proceed, `Some` a rejection result to return instead.
99    fn approval_gate(&self, call: &ToolCall) -> Option<ToolResult> {
100        let approved = self
101            .pending
102            .lock()
103            .unwrap_or_else(PoisonError::into_inner)
104            .as_ref()
105            .is_some_and(|approval| approval.approve(call));
106        if approved {
107            None
108        } else {
109            Some(ToolResult::failure(&call.id, "rejected by user approval"))
110        }
111    }
112
113    async fn bash(
114        &self,
115        call: ToolCall,
116        progress: &mut (dyn for<'a> FnMut(&'a str) + Send),
117    ) -> ToolResult {
118        if let Some(rejection) = self.approval_gate(&call) {
119            return rejection;
120        }
121        let Some(command) = arg_str(&call.args, "command") else {
122            return ToolResult::failure(call.id, "missing required argument: command");
123        };
124        // kill_on_drop so the timeout dropping the child handle kills the
125        // process; without it an expired command would keep running.
126        let mut child = match tokio::process::Command::new("bash")
127            .arg("-c")
128            .arg(&command)
129            .current_dir(&self.workspace)
130            .stdout(Stdio::piped())
131            .stderr(Stdio::piped())
132            .kill_on_drop(true)
133            .spawn()
134        {
135            Ok(child) => child,
136            Err(err) => {
137                return ToolResult::failure(call.id, format!("failed to spawn bash: {err}"));
138            }
139        };
140        // One reader per stream, complete lines into one channel: each
141        // line streams live through `progress` and lands in its
142        // stream's tail for the result.
143        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
144        if let Some(stdout) = child.stdout.take() {
145            pump(stdout, false, tx.clone());
146        }
147        if let Some(stderr) = child.stderr.take() {
148            pump(stderr, true, tx.clone());
149        }
150        drop(tx);
151        let deadline = tokio::time::Instant::now() + BASH_TIMEOUT;
152        let mut stdout: Vec<u8> = Vec::new();
153        let mut stderr: Vec<u8> = Vec::new();
154        loop {
155            tokio::select! {
156                _ = tokio::time::sleep_until(deadline) => {
157                    let _ = child.start_kill();
158                    let _ = child.wait().await;
159                    return ToolResult::failure(
160                        call.id,
161                        format!(
162                            "command timed out after {}s and was killed",
163                            BASH_TIMEOUT.as_secs()
164                        ),
165                    );
166                }
167                line = rx.recv() => match line {
168                    Some((is_stderr, line)) => {
169                        let sink = if is_stderr { &mut stderr } else { &mut stdout };
170                        sink.extend_from_slice(line.as_bytes());
171                        sink.push(b'\n');
172                        progress(&format!("{line}\n"));
173                    }
174                    // Both streams closed: only the exit status is left.
175                    None => break,
176                }
177            }
178        }
179        let status = match tokio::time::timeout(BASH_TIMEOUT, child.wait()).await {
180            Ok(Ok(status)) => status,
181            // A process that closed its streams but never exits dies with
182            // the handle; the caller reads a timeout like any other.
183            _ => {
184                return ToolResult::failure(
185                    call.id,
186                    format!(
187                        "command timed out after {}s and was killed",
188                        BASH_TIMEOUT.as_secs()
189                    ),
190                );
191            }
192        };
193        let content = format_command_output(&status, &stdout, &stderr);
194        let result = if status.success() {
195            ToolResult::success(call.id, content)
196        } else {
197            ToolResult::failure(call.id, content)
198        };
199        match status.code() {
200            Some(code) => result.with_code(code),
201            None => result,
202        }
203    }
204}
205
206#[async_trait]
207impl ToolResolver for ShellTools {
208    fn specs(&self) -> Vec<ToolSpec> {
209        Self::builtin_specs()
210    }
211
212    async fn resolve(&self, call: ToolCall, ctx: &ToolContext) -> ToolResult {
213        // A fn item, not a closure: its lifetime-generic signature is
214        // what the higher-ranked progress bound needs.
215        self.resolve_streaming(call, ctx, &mut silent).await
216    }
217
218    async fn resolve_streaming(
219        &self,
220        call: ToolCall,
221        ctx: &ToolContext,
222        progress: &mut (dyn for<'a> FnMut(&'a str) + Send),
223    ) -> ToolResult {
224        if ctx.cancelled.load(Ordering::Relaxed) {
225            return ToolResult::failure(call.id, "cancelled");
226        }
227        // The name is cloned out because the match arms move `call`.
228        let name = call.name.clone();
229        match name.as_str() {
230            "bash" => self.bash(call, progress).await,
231            other => ToolResult::failure(call.id, format!("unknown tool: {other}")),
232        }
233    }
234}
235
236fn arg_str(args: &Value, key: &str) -> Option<String> {
237    args.get(key).and_then(Value::as_str).map(str::to_owned)
238}
239
240/// The no-op progress: the plain `resolve` path runs the same streaming
241/// code with nobody listening.
242fn silent(_: &str) {}
243
244/// Reads `stream` line by line into `tx`, tagged with its stream, until
245/// EOF — the live side of the command's output. Detached: it ends with
246/// the pipe, whether the command finishes, times out, or is killed.
247fn pump<R>(
248    stream: R,
249    is_stderr: bool,
250    tx: tokio::sync::mpsc::UnboundedSender<(bool, String)>,
251) -> tokio::task::JoinHandle<()>
252where
253    R: tokio::io::AsyncRead + Unpin + Send + 'static,
254{
255    tokio::spawn(async move {
256        let mut lines = BufReader::new(stream).lines();
257        while let Ok(Some(line)) = lines.next_line().await {
258            if tx.send((is_stderr, line)).is_err() {
259                break; // the resolver is gone; nothing to stream to
260            }
261        }
262    })
263}
264
265/// Canonicalizes `path`, falling back to the input when it does not exist
266/// yet.
267fn canonicalized(path: PathBuf) -> PathBuf {
268    std::fs::canonicalize(&path).unwrap_or(path)
269}
270
271/// Lossy text of `bytes`, tail-kept at `limit` bytes and `max_lines`
272/// lines (whichever is hit first): the elided head is counted in a
273/// marker, and the byte cut lands on a line start so the first kept row
274/// is whole. A single line longer than the whole byte budget is kept
275/// partially, past the marker.
276fn render_truncated(bytes: &[u8], limit: usize, max_lines: usize) -> String {
277    let line_count = bytes.iter().filter(|&&byte| byte == b'\n').count();
278    if bytes.len() <= limit && line_count <= max_lines {
279        return String::from_utf8_lossy(bytes).into_owned();
280    }
281    let byte_cut = bytes.len().saturating_sub(limit);
282    let byte_cut = match bytes[..byte_cut].iter().rposition(|&byte| byte == b'\n') {
283        Some(pos) => pos + 1,
284        None => 0,
285    };
286    // The line cut: the start of the row that begins the last
287    // `max_lines` rows.
288    let mut line_cut = 0usize;
289    let mut seen = 0usize;
290    for (index, &byte) in bytes.iter().enumerate().rev() {
291        if byte == b'\n' {
292            seen += 1;
293            if seen == max_lines {
294                line_cut = index + 1;
295                break;
296            }
297        }
298    }
299    let cut = byte_cut.max(line_cut);
300    let mut text = format!("[output truncated — {} bytes elided]\n", cut);
301    text.push_str(&String::from_utf8_lossy(&bytes[cut..]));
302    text
303}
304
305fn format_command_output(
306    status: &std::process::ExitStatus,
307    stdout: &[u8],
308    stderr: &[u8],
309) -> String {
310    let code = match status.code() {
311        Some(code) => format!("exit code {code}"),
312        None => "terminated by signal".to_string(),
313    };
314    format!(
315        "{code}\n--- stdout ---\n{}\n--- stderr ---\n{}",
316        render_truncated(stdout, BASH_OUTPUT_LIMIT, BASH_OUTPUT_LINES),
317        render_truncated(stderr, BASH_OUTPUT_LIMIT, BASH_OUTPUT_LINES),
318    )
319}
320
321#[cfg(test)]
322mod tests {
323    use super::*;
324
325    #[test]
326    fn truncation_keeps_the_tail_and_counts_the_elided_head() {
327        let bytes = b"head line\nerror: the real failure\n".to_vec();
328        let text = render_truncated(&bytes, 10, 2000);
329        assert!(text.starts_with("[output truncated"), "{text}");
330        assert!(text.contains("error: the real failure"), "{text}");
331        // The cut lands on a line start: the first kept row is whole.
332        assert!(!text.contains("head line"), "{text}");
333    }
334
335    #[test]
336    fn the_line_limit_cuts_more_rows_than_the_byte_limit() {
337        let bytes = (0..3000).map(|i| format!("row {i}\n")).collect::<String>();
338        let text = render_truncated(bytes.as_bytes(), 50 * 1024, 2000);
339        // The last 2000 rows minus one: the cut lands after row 1000's
340        // newline, so rows 1001..2999 survive.
341        assert!(text.starts_with("[output truncated — "), "{text}");
342        assert!(text.contains("row 1001\n"), "{text}");
343        assert!(text.contains("row 2999\n"), "{text}");
344        assert!(!text.contains("row 1000\n"), "{text}");
345        assert!(!text.contains("row 999\n"), "{text}");
346    }
347
348    #[test]
349    fn output_within_the_limits_passes_untouched() {
350        assert_eq!(render_truncated(b"clean\n", 10, 2000), "clean\n");
351    }
352
353    #[tokio::test]
354    async fn a_command_streams_its_lines_before_its_result() {
355        let tools = ShellTools::new(std::env::temp_dir());
356        tools.install_approval(Arc::new(crate::approval::AllowAll));
357        let call = ToolCall {
358            id: "call_1".into(),
359            name: "bash".into(),
360            args: json!({ "command": "printf 'one\\ntwo\\n'; echo three" }),
361            provider_data: Value::Null,
362        };
363        let streamed = Arc::new(Mutex::new(Vec::new()));
364        let collector = streamed.clone();
365        let mut progress = move |chunk: &str| {
366            collector
367                .lock()
368                .unwrap_or_else(PoisonError::into_inner)
369                .push(chunk.to_owned());
370        };
371        let result = tools
372            .resolve_streaming(call, &ToolContext::default(), &mut progress)
373            .await;
374
375        // Every line rode the progress live, newline included.
376        assert_eq!(
377            *streamed.lock().unwrap_or_else(PoisonError::into_inner),
378            vec!["one\n".to_owned(), "two\n".to_owned(), "three\n".to_owned()]
379        );
380        // The result still carries the whole output, stdout and stderr
381        // in their own sections.
382        assert!(!result.is_error);
383        assert!(result.content.contains("--- stdout ---\n"));
384        assert!(result.content.contains("one\ntwo\nthree"));
385        tools.clear_approval();
386    }
387
388    #[tokio::test]
389    async fn stderr_streams_too_and_the_exit_code_rides_the_result() {
390        let tools = ShellTools::new(std::env::temp_dir());
391        tools.install_approval(Arc::new(crate::approval::AllowAll));
392        let call = ToolCall {
393            id: "call_1".into(),
394            name: "bash".into(),
395            args: json!({ "command": "echo oops >&2; exit 3" }),
396            provider_data: Value::Null,
397        };
398        let streamed = Arc::new(Mutex::new(Vec::new()));
399        let collector = streamed.clone();
400        let mut progress = move |chunk: &str| {
401            collector
402                .lock()
403                .unwrap_or_else(PoisonError::into_inner)
404                .push(chunk.to_owned());
405        };
406        let result = tools
407            .resolve_streaming(call, &ToolContext::default(), &mut progress)
408            .await;
409
410        assert_eq!(
411            *streamed.lock().unwrap_or_else(PoisonError::into_inner),
412            vec!["oops\n".to_owned()]
413        );
414        assert!(result.is_error);
415        assert_eq!(result.code, Some(3));
416        assert!(result.content.contains("oops"));
417        tools.clear_approval();
418    }
419}