1use 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
27const BASH_OUTPUT_LIMIT: usize = 50 * 1024;
31const BASH_OUTPUT_LINES: usize = 2000;
32const BASH_TIMEOUT: Duration = Duration::from_secs(120);
34
35pub struct ShellTools {
38 workspace: PathBuf,
40 pending: Mutex<Option<Arc<dyn Approval>>>,
43}
44
45impl ShellTools {
46 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 pub fn new(workspace: impl Into<PathBuf>) -> Self {
74 Self {
75 workspace: canonicalized(workspace.into()),
76 pending: Mutex::new(None),
77 }
78 }
79
80 pub fn workspace(&self) -> &Path {
82 &self.workspace
83 }
84
85 pub fn install_approval(&self, approval: Arc<dyn Approval>) {
89 *self.pending.lock().unwrap_or_else(PoisonError::into_inner) = Some(approval);
90 }
91
92 pub fn clear_approval(&self) {
95 *self.pending.lock().unwrap_or_else(PoisonError::into_inner) = None;
96 }
97
98 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 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 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 None => break,
176 }
177 }
178 }
179 let status = match tokio::time::timeout(BASH_TIMEOUT, child.wait()).await {
180 Ok(Ok(status)) => status,
181 _ => {
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 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 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
240fn silent(_: &str) {}
243
244fn 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; }
261 }
262 })
263}
264
265fn canonicalized(path: PathBuf) -> PathBuf {
268 std::fs::canonicalize(&path).unwrap_or(path)
269}
270
271fn 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 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 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 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 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 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}