1use std::collections::HashMap;
7use std::path::{Path, PathBuf};
8use std::time::Duration;
9
10use glob::glob as glob_match;
11use regex::{Regex, RegexBuilder};
12use serde_json::Value;
13use tokio::io::AsyncReadExt;
14use tokio::process::Command;
15use tracing::{debug, warn};
16
17use crate::error::{AgentError, Result};
18use crate::types::tools::{
19 BashInput, FileEditInput, FileReadInput, FileWriteInput, GlobInput, GrepInput, GrepOutputMode,
20};
21
22#[derive(Debug, Clone)]
24pub struct ToolResult {
25 pub content: String,
27 pub is_error: bool,
29 pub raw_content: Option<serde_json::Value>,
32}
33
34impl ToolResult {
35 fn ok(content: String) -> Self {
37 Self {
38 content,
39 is_error: false,
40 raw_content: None,
41 }
42 }
43
44 fn err(content: String) -> Self {
46 Self {
47 content,
48 is_error: true,
49 raw_content: None,
50 }
51 }
52}
53
54async fn drain_pipe<R: tokio::io::AsyncRead + Unpin>(handle: Option<R>) -> Vec<u8> {
58 let mut buf = Vec::new();
59 let Some(mut reader) = handle else {
60 return buf;
61 };
62 let mut chunk = [0u8; 65536];
63 loop {
64 match tokio::time::timeout(Duration::from_millis(10), reader.read(&mut chunk)).await {
65 Ok(Ok(0)) => break, Ok(Ok(n)) => buf.extend_from_slice(&chunk[..n]),
67 Ok(Err(_)) => break, Err(_) => break, }
70 }
71 buf
72}
73
74pub struct ToolExecutor {
76 cwd: PathBuf,
78 boundary: Option<PathBoundary>,
83 env_blocklist: Vec<String>,
86}
87
88struct PathBoundary {
97 allowed: Vec<PathBuf>,
99}
100
101impl PathBoundary {
102 fn new(cwd: &Path, additional: &[PathBuf]) -> Self {
104 let mut allowed = Vec::with_capacity(1 + additional.len());
105
106 let push_canon = |dirs: &mut Vec<PathBuf>, p: &Path| {
109 dirs.push(p.canonicalize().unwrap_or_else(|_| p.to_path_buf()));
110 };
111
112 push_canon(&mut allowed, cwd);
113 for dir in additional {
114 push_canon(&mut allowed, dir);
115 }
116
117 Self { allowed }
118 }
119
120 fn check(&self, path: &Path) -> std::result::Result<(), ToolResult> {
128 let normalized = Self::normalize(path)?;
129
130 for allowed in &self.allowed {
131 if normalized.starts_with(allowed) {
132 return Ok(());
133 }
134 }
135
136 Err(ToolResult::err(format!(
137 "Access denied: {} is outside the allowed directories",
138 path.display()
139 )))
140 }
141
142 fn normalize(path: &Path) -> std::result::Result<PathBuf, ToolResult> {
149 if let Ok(canon) = path.canonicalize() {
151 return Ok(canon);
152 }
153
154 let mut remaining = Vec::new();
156 let mut ancestor = path.to_path_buf();
157
158 loop {
159 if ancestor.exists() {
160 let base = ancestor.canonicalize().map_err(|_| {
161 ToolResult::err(format!("Access denied: cannot resolve {}", path.display()))
162 })?;
163
164 let mut result = base;
166 for component in remaining.iter().rev() {
167 result = result.join(component);
168 }
169 return Ok(result);
170 }
171
172 match ancestor.file_name() {
173 Some(name) => {
174 let name = name.to_os_string();
175 remaining.push(name);
176 if !ancestor.pop() {
177 break;
178 }
179 }
180 None => break,
181 }
182 }
183
184 Err(ToolResult::err(format!(
186 "Access denied: cannot resolve {}",
187 path.display()
188 )))
189 }
190}
191
192impl ToolExecutor {
193 pub fn new(cwd: PathBuf) -> Self {
195 Self {
196 cwd,
197 boundary: None,
198 env_blocklist: Vec::new(),
199 }
200 }
201
202 pub fn with_allowed_dirs(cwd: PathBuf, additional: Vec<PathBuf>) -> Self {
205 let boundary = PathBoundary::new(&cwd, &additional);
206 Self {
207 cwd,
208 boundary: Some(boundary),
209 env_blocklist: Vec::new(),
210 }
211 }
212
213 pub fn with_env_blocklist(mut self, blocklist: Vec<String>) -> Self {
215 self.env_blocklist = blocklist;
216 self
217 }
218
219 pub async fn execute(&self, tool_name: &str, input: Value) -> Result<ToolResult> {
221 debug!(tool = tool_name, "executing built-in tool");
222
223 match tool_name {
224 "Read" => {
225 let params: FileReadInput = serde_json::from_value(input)?;
226 self.execute_read(¶ms).await
227 }
228 "Write" => {
229 let params: FileWriteInput = serde_json::from_value(input)?;
230 self.execute_write(¶ms).await
231 }
232 "Edit" => {
233 let params: FileEditInput = serde_json::from_value(input)?;
234 self.execute_edit(¶ms).await
235 }
236 "Bash" => {
237 let params: BashInput = serde_json::from_value(input)?;
238 self.execute_bash(¶ms).await
239 }
240 "Glob" => {
241 let params: GlobInput = serde_json::from_value(input)?;
242 self.execute_glob(¶ms).await
243 }
244 "Grep" => {
245 let params: GrepInput = serde_json::from_value(input)?;
246 self.execute_grep(¶ms).await
247 }
248 _ => Err(AgentError::ToolExecution(format!(
249 "unsupported built-in tool: {tool_name}. Supported built-in tools: Read, Write, Edit, Bash, Glob, Grep"
250 ))),
251 }
252 }
253
254 fn resolve_and_check(&self, path: &str) -> std::result::Result<PathBuf, ToolResult> {
259 let p = Path::new(path);
260 let resolved = if p.is_absolute() {
261 p.to_path_buf()
262 } else {
263 self.cwd.join(p)
264 };
265
266 if let Some(ref boundary) = self.boundary {
267 boundary.check(&resolved)?;
268 }
269
270 Ok(resolved)
271 }
272
273 async fn execute_read(&self, input: &FileReadInput) -> Result<ToolResult> {
280 let path = match self.resolve_and_check(&input.file_path) {
281 Ok(p) => p,
282 Err(denied) => return Ok(denied),
283 };
284
285 let ext = path
287 .extension()
288 .unwrap_or_default()
289 .to_string_lossy()
290 .to_lowercase();
291
292 let media_type = match ext.as_str() {
293 "png" => Some("image/png"),
294 "jpg" | "jpeg" => Some("image/jpeg"),
295 "gif" => Some("image/gif"),
296 "webp" => Some("image/webp"),
297 _ => None,
298 };
299
300 if let Some(media_type) = media_type {
301 return self.read_image(&path, media_type).await;
302 }
303
304 let content = match tokio::fs::read_to_string(&path).await {
305 Ok(c) => c,
306 Err(e) => {
307 return Ok(ToolResult::err(format!(
308 "Failed to read {}: {e}",
309 path.display()
310 )));
311 }
312 };
313
314 let lines: Vec<&str> = content.lines().collect();
315 let total = lines.len();
316
317 let offset = input.offset.unwrap_or(0) as usize;
318 let limit = input.limit.unwrap_or(total as u64) as usize;
319
320 if offset >= total {
321 return Ok(ToolResult::ok(String::new()));
322 }
323
324 let end = (offset + limit).min(total);
325 let selected = &lines[offset..end];
326
327 let width = format!("{}", end).len();
329 let mut output = String::new();
330 for (i, line) in selected.iter().enumerate() {
331 let line_no = offset + i + 1; output.push_str(&format!("{line_no:>width$}\t{line}\n", width = width));
333 }
334
335 Ok(ToolResult::ok(output))
336 }
337
338 async fn read_image(&self, path: &Path, media_type: &str) -> Result<ToolResult> {
340 let bytes = match tokio::fs::read(path).await {
341 Ok(b) => b,
342 Err(e) => {
343 return Ok(ToolResult::err(format!(
344 "Failed to read {}: {e}",
345 path.display()
346 )));
347 }
348 };
349
350 if bytes.len() > 20 * 1024 * 1024 {
352 return Ok(ToolResult::err(format!(
353 "Image too large ({:.1} MB, max 20 MB): {}",
354 bytes.len() as f64 / (1024.0 * 1024.0),
355 path.display()
356 )));
357 }
358
359 use base64::Engine;
360 let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes);
361
362 Ok(ToolResult {
363 content: format!("Image: {}", path.display()),
364 is_error: false,
365 raw_content: Some(serde_json::json!([
366 {
367 "type": "image",
368 "source": {
369 "type": "base64",
370 "media_type": media_type,
371 "data": b64,
372 }
373 }
374 ])),
375 })
376 }
377
378 async fn execute_write(&self, input: &FileWriteInput) -> Result<ToolResult> {
382 let path = match self.resolve_and_check(&input.file_path) {
383 Ok(p) => p,
384 Err(denied) => return Ok(denied),
385 };
386
387 if let Some(parent) = path.parent() {
389 if let Err(e) = tokio::fs::create_dir_all(parent).await {
390 return Ok(ToolResult::err(format!(
391 "Failed to create directories for {}: {e}",
392 path.display()
393 )));
394 }
395 }
396
397 match tokio::fs::write(&path, &input.content).await {
398 Ok(()) => Ok(ToolResult::ok(format!(
399 "Successfully wrote to {}",
400 path.display()
401 ))),
402 Err(e) => Ok(ToolResult::err(format!(
403 "Failed to write {}: {e}",
404 path.display()
405 ))),
406 }
407 }
408
409 async fn execute_edit(&self, input: &FileEditInput) -> Result<ToolResult> {
417 let path = match self.resolve_and_check(&input.file_path) {
418 Ok(p) => p,
419 Err(denied) => return Ok(denied),
420 };
421
422 let content = match tokio::fs::read_to_string(&path).await {
423 Ok(c) => c,
424 Err(e) => {
425 return Ok(ToolResult::err(format!(
426 "Failed to read {}: {e}",
427 path.display()
428 )));
429 }
430 };
431
432 let replace_all = input.replace_all.unwrap_or(false);
433 let count = content.matches(&input.old_string).count();
434
435 if count == 0 {
436 return Ok(ToolResult::err(format!(
437 "old_string not found in {}. Make sure it matches exactly, including whitespace and indentation.",
438 path.display()
439 )));
440 }
441
442 if count > 1 && !replace_all {
443 return Ok(ToolResult::err(format!(
444 "old_string found {count} times in {}. Provide more surrounding context to make it unique, or set replace_all to true.",
445 path.display()
446 )));
447 }
448
449 let new_content = if replace_all {
450 content.replace(&input.old_string, &input.new_string)
451 } else {
452 content.replacen(&input.old_string, &input.new_string, 1)
454 };
455
456 match tokio::fs::write(&path, &new_content).await {
457 Ok(()) => {
458 let replacements = if replace_all {
459 format!("{count} replacement(s)")
460 } else {
461 "1 replacement".to_string()
462 };
463 Ok(ToolResult::ok(format!(
464 "Successfully edited {} ({replacements})",
465 path.display()
466 )))
467 }
468 Err(e) => Ok(ToolResult::err(format!(
469 "Failed to write {}: {e}",
470 path.display()
471 ))),
472 }
473 }
474
475 async fn execute_bash(&self, input: &BashInput) -> Result<ToolResult> {
480 if input.run_in_background == Some(true) {
482 let mut cmd = Command::new("/bin/bash");
483 cmd.arg("-c")
484 .arg(&input.command)
485 .current_dir(&self.cwd)
486 .env("HOME", &self.cwd)
487 .stdout(std::process::Stdio::null())
488 .stderr(std::process::Stdio::null());
489 for key in &self.env_blocklist {
490 cmd.env_remove(key);
491 }
492 let child = cmd.spawn();
493
494 return match child {
495 Ok(child) => {
496 let pid = child.id().unwrap_or(0);
497 Ok(ToolResult {
498 content: format!("Process started in background (pid: {pid})"),
499 is_error: false,
500 raw_content: None,
501 })
502 }
503 Err(e) => Ok(ToolResult::err(format!("Failed to spawn process: {e}"))),
504 };
505 }
506
507 let timeout_ms = input.timeout.unwrap_or(120_000);
508 let timeout_dur = Duration::from_millis(timeout_ms);
509
510 let mut cmd = Command::new("/bin/bash");
511 cmd.arg("-c")
512 .arg(&input.command)
513 .current_dir(&self.cwd)
514 .env("HOME", &self.cwd)
515 .stdout(std::process::Stdio::piped())
516 .stderr(std::process::Stdio::piped());
517 for key in &self.env_blocklist {
518 cmd.env_remove(key);
519 }
520 let child = cmd.spawn();
521
522 let mut child = match child {
523 Ok(c) => c,
524 Err(e) => {
525 return Ok(ToolResult::err(format!("Failed to spawn process: {e}")));
526 }
527 };
528
529 let stdout_handle = child.stdout.take();
531 let stderr_handle = child.stderr.take();
532
533 let wait_result = tokio::time::timeout(timeout_dur, child.wait()).await;
535
536 match wait_result {
537 Ok(Ok(status)) => {
538 let (stdout_bytes, stderr_bytes) =
542 tokio::join!(drain_pipe(stdout_handle), drain_pipe(stderr_handle),);
543
544 let stdout = String::from_utf8_lossy(&stdout_bytes);
545 let stderr = String::from_utf8_lossy(&stderr_bytes);
546
547 let mut combined = String::new();
548 if !stdout.is_empty() {
549 combined.push_str(&stdout);
550 }
551 if !stderr.is_empty() {
552 if !combined.is_empty() {
553 combined.push('\n');
554 }
555 combined.push_str(&stderr);
556 }
557
558 let is_error = !status.success();
559 if is_error && combined.is_empty() {
560 combined = format!("Process exited with code {}", status.code().unwrap_or(-1));
561 }
562
563 Ok(ToolResult {
564 content: combined,
565 is_error,
566 raw_content: None,
567 })
568 }
569 Ok(Err(e)) => Ok(ToolResult::err(format!("Process IO error: {e}"))),
570 Err(_) => {
571 let _ = child.kill().await;
573 Ok(ToolResult::err(format!(
574 "Command timed out after {timeout_ms}ms"
575 )))
576 }
577 }
578 }
579
580 async fn execute_glob(&self, input: &GlobInput) -> Result<ToolResult> {
584 let base = match &input.path {
585 Some(p) => match self.resolve_and_check(p) {
586 Ok(resolved) => resolved,
587 Err(denied) => return Ok(denied),
588 },
589 None => self.cwd.clone(),
590 };
591
592 let full_pattern = base.join(&input.pattern);
593 let pattern_str = full_pattern.to_string_lossy().to_string();
594
595 let result =
597 tokio::task::spawn_blocking(move || -> std::result::Result<Vec<String>, String> {
598 let entries =
599 glob_match(&pattern_str).map_err(|e| format!("Invalid glob pattern: {e}"))?;
600
601 let mut paths: Vec<String> = Vec::new();
602 for entry in entries {
603 match entry {
604 Ok(p) => paths.push(p.to_string_lossy().to_string()),
605 Err(e) => {
606 warn!("glob entry error: {e}");
607 }
608 }
609 }
610 paths.sort();
611 Ok(paths)
612 })
613 .await
614 .map_err(|e| AgentError::ToolExecution(format!("glob task panicked: {e}")))?;
615
616 match result {
617 Ok(paths) => {
618 if paths.is_empty() {
619 Ok(ToolResult::ok("No files matched the pattern.".to_string()))
620 } else {
621 Ok(ToolResult::ok(paths.join("\n")))
622 }
623 }
624 Err(e) => Ok(ToolResult::err(e)),
625 }
626 }
627
628 async fn execute_grep(&self, input: &GrepInput) -> Result<ToolResult> {
633 if let Some(ref p) = input.path {
634 if let Err(denied) = self.resolve_and_check(p) {
635 return Ok(denied);
636 }
637 }
638
639 let input = input.clone();
640 let cwd = self.cwd.clone();
641
642 let result = tokio::task::spawn_blocking(move || grep_sync(&input, &cwd))
644 .await
645 .map_err(|e| AgentError::ToolExecution(format!("grep task panicked: {e}")))?;
646
647 result
648 }
649}
650
651fn extensions_for_type(file_type: &str) -> Option<Vec<&'static str>> {
655 let map: HashMap<&str, Vec<&str>> = HashMap::from([
656 ("rust", vec!["rs"]),
657 ("rs", vec!["rs"]),
658 ("py", vec!["py", "pyi"]),
659 ("python", vec!["py", "pyi"]),
660 ("js", vec!["js", "mjs", "cjs"]),
661 ("ts", vec!["ts", "tsx", "mts", "cts"]),
662 ("go", vec!["go"]),
663 ("java", vec!["java"]),
664 ("c", vec!["c", "h"]),
665 ("cpp", vec!["cpp", "cxx", "cc", "hpp", "hxx", "hh", "h"]),
666 ("rb", vec!["rb"]),
667 ("ruby", vec!["rb"]),
668 ("html", vec!["html", "htm"]),
669 ("css", vec!["css"]),
670 ("json", vec!["json"]),
671 ("yaml", vec!["yaml", "yml"]),
672 ("toml", vec!["toml"]),
673 ("md", vec!["md", "markdown"]),
674 ("sh", vec!["sh", "bash", "zsh"]),
675 ("sql", vec!["sql"]),
676 ("xml", vec!["xml"]),
677 ("swift", vec!["swift"]),
678 ("kt", vec!["kt", "kts"]),
679 ("scala", vec!["scala"]),
680 ]);
681 map.get(file_type).cloned()
682}
683
684fn matches_file_filter(
686 path: &Path,
687 glob_filter: &Option<glob::Pattern>,
688 type_exts: &Option<Vec<&str>>,
689) -> bool {
690 if let Some(pat) = glob_filter {
691 let name = path.file_name().unwrap_or_default().to_string_lossy();
692 if !pat.matches(&name) {
693 return false;
694 }
695 }
696 if let Some(exts) = type_exts {
697 let ext = path
698 .extension()
699 .unwrap_or_default()
700 .to_string_lossy()
701 .to_lowercase();
702 if !exts.contains(&ext.as_str()) {
703 return false;
704 }
705 }
706 true
707}
708
709fn walk_files(dir: &Path) -> Vec<PathBuf> {
711 let mut files = Vec::new();
712 walk_files_recursive(dir, &mut files);
713 files.sort();
714 files
715}
716
717fn walk_files_recursive(dir: &Path, out: &mut Vec<PathBuf>) {
718 let entries = match std::fs::read_dir(dir) {
719 Ok(e) => e,
720 Err(_) => return,
721 };
722 for entry in entries.flatten() {
723 let path = entry.path();
724 let name = entry.file_name();
725 let name_str = name.to_string_lossy();
726
727 if name_str.starts_with('.') || name_str == "node_modules" || name_str == "target" {
729 continue;
730 }
731
732 if path.is_dir() {
733 walk_files_recursive(&path, out);
734 } else if path.is_file() {
735 out.push(path);
736 }
737 }
738}
739
740fn grep_sync(input: &GrepInput, cwd: &Path) -> Result<ToolResult> {
742 let output_mode = input
743 .output_mode
744 .clone()
745 .unwrap_or(GrepOutputMode::FilesWithMatches);
746 let case_insensitive = input.case_insensitive.unwrap_or(false);
747 let show_line_numbers = input.line_numbers.unwrap_or(true);
748 let multiline = input.multiline.unwrap_or(false);
749
750 let context_lines = input.context.or(input.context_alias);
752 let before_context = input.before_context.or(context_lines).unwrap_or(0) as usize;
753 let after_context = input.after_context.or(context_lines).unwrap_or(0) as usize;
754
755 let head_limit = input.head_limit.unwrap_or(0) as usize;
756 let offset = input.offset.unwrap_or(0) as usize;
757
758 let re = RegexBuilder::new(&input.pattern)
760 .case_insensitive(case_insensitive)
761 .multi_line(multiline)
762 .dot_matches_new_line(multiline)
763 .build()?;
764
765 let search_path = match &input.path {
767 Some(p) => {
768 let resolved = if Path::new(p).is_absolute() {
769 PathBuf::from(p)
770 } else {
771 cwd.join(p)
772 };
773 resolved
774 }
775 None => cwd.to_path_buf(),
776 };
777
778 let glob_filter = input
780 .glob
781 .as_ref()
782 .map(|g| glob::Pattern::new(g).unwrap_or_else(|_| glob::Pattern::new("*").unwrap()));
783 let type_exts = input
784 .file_type
785 .as_ref()
786 .and_then(|t| extensions_for_type(t).map(|v| v.into_iter().collect::<Vec<_>>()));
787
788 let files = if search_path.is_file() {
790 vec![search_path.clone()]
791 } else {
792 walk_files(&search_path)
793 };
794
795 let files: Vec<PathBuf> = files
797 .into_iter()
798 .filter(|f| matches_file_filter(f, &glob_filter, &type_exts))
799 .collect();
800
801 match output_mode {
802 GrepOutputMode::FilesWithMatches => {
803 grep_files_with_matches(&re, &files, offset, head_limit)
804 }
805 GrepOutputMode::Count => grep_count(&re, &files, offset, head_limit),
806 GrepOutputMode::Content => grep_content(
807 &re,
808 &files,
809 before_context,
810 after_context,
811 show_line_numbers,
812 offset,
813 head_limit,
814 ),
815 }
816}
817
818fn grep_files_with_matches(
819 re: &Regex,
820 files: &[PathBuf],
821 offset: usize,
822 head_limit: usize,
823) -> Result<ToolResult> {
824 let mut matched: Vec<String> = Vec::new();
825 for file in files {
826 if let Ok(content) = std::fs::read_to_string(file) {
827 if re.is_match(&content) {
828 matched.push(file.to_string_lossy().to_string());
829 }
830 }
831 }
832
833 let result = apply_offset_limit(matched, offset, head_limit);
834 if result.is_empty() {
835 Ok(ToolResult::ok("No matches found.".to_string()))
836 } else {
837 Ok(ToolResult::ok(result.join("\n")))
838 }
839}
840
841fn grep_count(
842 re: &Regex,
843 files: &[PathBuf],
844 offset: usize,
845 head_limit: usize,
846) -> Result<ToolResult> {
847 let mut entries: Vec<String> = Vec::new();
848 for file in files {
849 if let Ok(content) = std::fs::read_to_string(file) {
850 let count = re.find_iter(&content).count();
851 if count > 0 {
852 entries.push(format!("{}:{count}", file.to_string_lossy()));
853 }
854 }
855 }
856
857 let result = apply_offset_limit(entries, offset, head_limit);
858 if result.is_empty() {
859 Ok(ToolResult::ok("No matches found.".to_string()))
860 } else {
861 Ok(ToolResult::ok(result.join("\n")))
862 }
863}
864
865fn grep_content(
866 re: &Regex,
867 files: &[PathBuf],
868 before_context: usize,
869 after_context: usize,
870 show_line_numbers: bool,
871 offset: usize,
872 head_limit: usize,
873) -> Result<ToolResult> {
874 let mut output_lines: Vec<String> = Vec::new();
875
876 for file in files {
877 let content = match std::fs::read_to_string(file) {
878 Ok(c) => c,
879 Err(_) => continue,
880 };
881
882 let lines: Vec<&str> = content.lines().collect();
883 let file_display = file.to_string_lossy();
884
885 let mut matching_line_indices: Vec<usize> = Vec::new();
887 for (i, line) in lines.iter().enumerate() {
888 if re.is_match(line) {
889 matching_line_indices.push(i);
890 }
891 }
892
893 if matching_line_indices.is_empty() {
894 continue;
895 }
896
897 let mut display_set = Vec::new();
899 for &idx in &matching_line_indices {
900 let start = idx.saturating_sub(before_context);
901 let end = (idx + after_context + 1).min(lines.len());
902 for i in start..end {
903 display_set.push(i);
904 }
905 }
906 display_set.sort();
907 display_set.dedup();
908
909 let mut prev: Option<usize> = None;
911 for &line_idx in &display_set {
912 if let Some(p) = prev {
913 if line_idx > p + 1 {
914 output_lines.push("--".to_string());
915 }
916 }
917
918 let line_content = lines[line_idx];
919 if show_line_numbers {
920 let sep = if matching_line_indices.contains(&line_idx) {
921 ':'
922 } else {
923 '-'
924 };
925 output_lines.push(format!(
926 "{file_display}{sep}{}{sep}{line_content}",
927 line_idx + 1
928 ));
929 } else {
930 output_lines.push(format!("{file_display}:{line_content}"));
931 }
932
933 prev = Some(line_idx);
934 }
935 }
936
937 let result = apply_offset_limit(output_lines, offset, head_limit);
938 if result.is_empty() {
939 Ok(ToolResult::ok("No matches found.".to_string()))
940 } else {
941 Ok(ToolResult::ok(result.join("\n")))
942 }
943}
944
945fn apply_offset_limit(items: Vec<String>, offset: usize, head_limit: usize) -> Vec<String> {
947 let after_offset: Vec<String> = items.into_iter().skip(offset).collect();
948 if head_limit > 0 {
949 after_offset.into_iter().take(head_limit).collect()
950 } else {
951 after_offset
952 }
953}
954
955#[cfg(test)]
956mod tests {
957 use super::*;
958 use serde_json::json;
959 use tempfile::TempDir;
960
961 fn setup() -> (TempDir, ToolExecutor) {
962 let tmp = TempDir::new().unwrap();
963 let executor = ToolExecutor::new(tmp.path().to_path_buf());
964 (tmp, executor)
965 }
966
967 #[tokio::test]
970 async fn read_text_file() {
971 let (tmp, executor) = setup();
972 let file = tmp.path().join("hello.txt");
973 std::fs::write(&file, "line one\nline two\nline three\n").unwrap();
974
975 let result = executor
976 .execute("Read", json!({ "file_path": file.to_str().unwrap() }))
977 .await
978 .unwrap();
979
980 assert!(!result.is_error);
981 assert!(result.raw_content.is_none());
982 assert!(result.content.contains("line one"));
983 assert!(result.content.contains("line three"));
984 }
985
986 #[tokio::test]
987 async fn read_text_file_with_offset_and_limit() {
988 let (tmp, executor) = setup();
989 let file = tmp.path().join("lines.txt");
990 std::fs::write(&file, "a\nb\nc\nd\ne\n").unwrap();
991
992 let result = executor
993 .execute(
994 "Read",
995 json!({ "file_path": file.to_str().unwrap(), "offset": 1, "limit": 2 }),
996 )
997 .await
998 .unwrap();
999
1000 assert!(!result.is_error);
1001 assert!(result.content.contains("b"));
1003 assert!(result.content.contains("c"));
1004 assert!(!result.content.contains("a"));
1005 assert!(!result.content.contains("d"));
1006 }
1007
1008 #[tokio::test]
1009 async fn read_missing_file_returns_error() {
1010 let (tmp, executor) = setup();
1011 let file = tmp.path().join("nope.txt");
1012
1013 let result = executor
1014 .execute("Read", json!({ "file_path": file.to_str().unwrap() }))
1015 .await
1016 .unwrap();
1017
1018 assert!(result.is_error);
1019 assert!(result.content.contains("Failed to read"));
1020 }
1021
1022 #[tokio::test]
1025 async fn read_png_returns_image_content_block() {
1026 let (tmp, executor) = setup();
1027 let file = tmp.path().join("test.png");
1028 let png_bytes = b"\x89PNG\r\n\x1a\nfake-png-payload";
1029 std::fs::write(&file, png_bytes).unwrap();
1030
1031 let result = executor
1032 .execute("Read", json!({ "file_path": file.to_str().unwrap() }))
1033 .await
1034 .unwrap();
1035
1036 assert!(!result.is_error);
1037 assert!(result.raw_content.is_some(), "image should set raw_content");
1038
1039 let blocks = result.raw_content.unwrap();
1040 let block = blocks.as_array().unwrap().first().unwrap();
1041 assert_eq!(block["type"], "image");
1042 assert_eq!(block["source"]["type"], "base64");
1043 assert_eq!(block["source"]["media_type"], "image/png");
1044 let data = block["source"]["data"].as_str().unwrap();
1046 assert!(!data.is_empty());
1047 use base64::Engine;
1048 let decoded = base64::engine::general_purpose::STANDARD
1049 .decode(data)
1050 .unwrap();
1051 assert_eq!(decoded, png_bytes);
1052 }
1053
1054 #[tokio::test]
1055 async fn read_jpeg_returns_image_content_block() {
1056 let (tmp, executor) = setup();
1057 let file = tmp.path().join("photo.jpg");
1060 let fake_jpeg = b"\xFF\xD8\xFF\xE0fake-jpeg-data";
1061 std::fs::write(&file, fake_jpeg).unwrap();
1062
1063 let result = executor
1064 .execute("Read", json!({ "file_path": file.to_str().unwrap() }))
1065 .await
1066 .unwrap();
1067
1068 assert!(!result.is_error);
1069 let blocks = result.raw_content.unwrap();
1070 let block = blocks.as_array().unwrap().first().unwrap();
1071 assert_eq!(block["source"]["media_type"], "image/jpeg");
1072 }
1073
1074 #[tokio::test]
1075 async fn read_jpeg_extension_detected() {
1076 let (tmp, executor) = setup();
1077 let file = tmp.path().join("photo.jpeg");
1078 std::fs::write(&file, b"data").unwrap();
1079
1080 let result = executor
1081 .execute("Read", json!({ "file_path": file.to_str().unwrap() }))
1082 .await
1083 .unwrap();
1084
1085 assert!(!result.is_error);
1086 let blocks = result.raw_content.unwrap();
1087 assert_eq!(blocks[0]["source"]["media_type"], "image/jpeg");
1088 }
1089
1090 #[tokio::test]
1091 async fn read_gif_returns_image_content_block() {
1092 let (tmp, executor) = setup();
1093 let file = tmp.path().join("anim.gif");
1094 std::fs::write(&file, b"GIF89adata").unwrap();
1095
1096 let result = executor
1097 .execute("Read", json!({ "file_path": file.to_str().unwrap() }))
1098 .await
1099 .unwrap();
1100
1101 assert!(!result.is_error);
1102 let blocks = result.raw_content.unwrap();
1103 assert_eq!(blocks[0]["source"]["media_type"], "image/gif");
1104 }
1105
1106 #[tokio::test]
1107 async fn read_webp_returns_image_content_block() {
1108 let (tmp, executor) = setup();
1109 let file = tmp.path().join("img.webp");
1110 std::fs::write(&file, b"RIFF\x00\x00\x00\x00WEBP").unwrap();
1111
1112 let result = executor
1113 .execute("Read", json!({ "file_path": file.to_str().unwrap() }))
1114 .await
1115 .unwrap();
1116
1117 assert!(!result.is_error);
1118 let blocks = result.raw_content.unwrap();
1119 assert_eq!(blocks[0]["source"]["media_type"], "image/webp");
1120 }
1121
1122 #[tokio::test]
1123 async fn read_missing_image_returns_error() {
1124 let (tmp, executor) = setup();
1125 let file = tmp.path().join("nope.png");
1126
1127 let result = executor
1128 .execute("Read", json!({ "file_path": file.to_str().unwrap() }))
1129 .await
1130 .unwrap();
1131
1132 assert!(result.is_error);
1133 assert!(result.content.contains("Failed to read"));
1134 assert!(result.raw_content.is_none());
1135 }
1136
1137 #[tokio::test]
1138 async fn read_non_image_extension_returns_text() {
1139 let (tmp, executor) = setup();
1140 let file = tmp.path().join("data.csv");
1141 std::fs::write(&file, "a,b,c\n1,2,3\n").unwrap();
1142
1143 let result = executor
1144 .execute("Read", json!({ "file_path": file.to_str().unwrap() }))
1145 .await
1146 .unwrap();
1147
1148 assert!(!result.is_error);
1149 assert!(
1150 result.raw_content.is_none(),
1151 "csv should not be treated as image"
1152 );
1153 assert!(result.content.contains("a,b,c"));
1154 }
1155
1156 #[test]
1159 fn tool_result_ok_has_no_raw_content() {
1160 let r = ToolResult::ok("hello".into());
1161 assert!(!r.is_error);
1162 assert!(r.raw_content.is_none());
1163 }
1164
1165 #[test]
1166 fn tool_result_err_has_no_raw_content() {
1167 let r = ToolResult::err("boom".into());
1168 assert!(r.is_error);
1169 assert!(r.raw_content.is_none());
1170 }
1171
1172 fn setup_sandboxed() -> (TempDir, TempDir, ToolExecutor) {
1175 let project = TempDir::new().unwrap();
1176 let data = TempDir::new().unwrap();
1177 let executor = ToolExecutor::with_allowed_dirs(
1178 project.path().to_path_buf(),
1179 vec![data.path().to_path_buf()],
1180 );
1181 (project, data, executor)
1182 }
1183
1184 #[tokio::test]
1185 async fn sandbox_allows_read_inside_cwd() {
1186 let (project, _data, executor) = setup_sandboxed();
1187 let file = project.path().join("hello.txt");
1188 std::fs::write(&file, "ok").unwrap();
1189
1190 let result = executor
1191 .execute("Read", json!({ "file_path": file.to_str().unwrap() }))
1192 .await
1193 .unwrap();
1194 assert!(!result.is_error);
1195 }
1196
1197 #[tokio::test]
1198 async fn sandbox_allows_read_inside_additional_dir() {
1199 let (_project, data, executor) = setup_sandboxed();
1200 let file = data.path().join("MEMORY.md");
1201 std::fs::write(&file, "# Memory").unwrap();
1202
1203 let result = executor
1204 .execute("Read", json!({ "file_path": file.to_str().unwrap() }))
1205 .await
1206 .unwrap();
1207 assert!(!result.is_error);
1208 assert!(result.content.contains("Memory"));
1209 }
1210
1211 #[tokio::test]
1212 async fn sandbox_denies_read_outside_boundaries() {
1213 let (_project, _data, executor) = setup_sandboxed();
1214 let outside = TempDir::new().unwrap();
1215 let file = outside.path().join("secret.txt");
1216 std::fs::write(&file, "secret data").unwrap();
1217
1218 let result = executor
1219 .execute("Read", json!({ "file_path": file.to_str().unwrap() }))
1220 .await
1221 .unwrap();
1222 assert!(result.is_error);
1223 assert!(result.content.contains("Access denied"));
1224 }
1225
1226 #[tokio::test]
1227 async fn sandbox_denies_write_outside_boundaries() {
1228 let (_project, _data, executor) = setup_sandboxed();
1229 let outside = TempDir::new().unwrap();
1230 let file = outside.path().join("hack.txt");
1231
1232 let result = executor
1233 .execute(
1234 "Write",
1235 json!({ "file_path": file.to_str().unwrap(), "content": "pwned" }),
1236 )
1237 .await
1238 .unwrap();
1239 assert!(result.is_error);
1240 assert!(result.content.contains("Access denied"));
1241 assert!(!file.exists());
1242 }
1243
1244 #[tokio::test]
1245 async fn sandbox_denies_edit_outside_boundaries() {
1246 let (_project, _data, executor) = setup_sandboxed();
1247 let outside = TempDir::new().unwrap();
1248 let file = outside.path().join("target.txt");
1249 std::fs::write(&file, "original").unwrap();
1250
1251 let result = executor
1252 .execute(
1253 "Edit",
1254 json!({
1255 "file_path": file.to_str().unwrap(),
1256 "old_string": "original",
1257 "new_string": "modified"
1258 }),
1259 )
1260 .await
1261 .unwrap();
1262 assert!(result.is_error);
1263 assert!(result.content.contains("Access denied"));
1264 }
1265
1266 #[tokio::test]
1267 async fn no_sandbox_when_allowed_dirs_empty() {
1268 let outside = TempDir::new().unwrap();
1270 let file = outside.path().join("free.txt");
1271 std::fs::write(&file, "open access").unwrap();
1272
1273 let executor = ToolExecutor::new(TempDir::new().unwrap().path().to_path_buf());
1274 let result = executor
1275 .execute("Read", json!({ "file_path": file.to_str().unwrap() }))
1276 .await
1277 .unwrap();
1278 assert!(!result.is_error);
1279 }
1280
1281 #[tokio::test]
1282 async fn sandbox_denies_dotdot_traversal() {
1283 let (project, _data, executor) = setup_sandboxed();
1284 let outside = TempDir::new().unwrap();
1286 let secret = outside.path().join("secret.txt");
1287 std::fs::write(&secret, "sensitive").unwrap();
1288
1289 let traversal = project
1291 .path()
1292 .join("..")
1293 .join("..")
1294 .join(outside.path().strip_prefix("/").unwrap())
1295 .join("secret.txt");
1296
1297 let result = executor
1298 .execute("Read", json!({ "file_path": traversal.to_str().unwrap() }))
1299 .await
1300 .unwrap();
1301 assert!(result.is_error);
1302 assert!(result.content.contains("Access denied"));
1303 }
1304
1305 #[tokio::test]
1306 async fn sandbox_allows_write_new_file_inside_cwd() {
1307 let (project, _data, executor) = setup_sandboxed();
1308 let file = project.path().join("subdir").join("new.txt");
1309
1310 let result = executor
1311 .execute(
1312 "Write",
1313 json!({ "file_path": file.to_str().unwrap(), "content": "hello" }),
1314 )
1315 .await
1316 .unwrap();
1317 assert!(!result.is_error);
1318 assert!(file.exists());
1319 }
1320
1321 #[tokio::test]
1322 async fn sandbox_denies_symlink_escape() {
1323 let (project, _data, executor) = setup_sandboxed();
1324 let outside = TempDir::new().unwrap();
1325 let secret = outside.path().join("secret.txt");
1326 std::fs::write(&secret, "sensitive").unwrap();
1327
1328 let link = project.path().join("escape");
1330 std::os::unix::fs::symlink(outside.path(), &link).unwrap();
1331
1332 let via_link = link.join("secret.txt");
1333 let result = executor
1334 .execute("Read", json!({ "file_path": via_link.to_str().unwrap() }))
1335 .await
1336 .unwrap();
1337 assert!(result.is_error);
1338 assert!(result.content.contains("Access denied"));
1339 }
1340
1341 #[tokio::test]
1342 async fn bash_sets_home_to_cwd() {
1343 let (tmp, executor) = setup();
1344
1345 let result = executor
1346 .execute("Bash", json!({ "command": "echo $HOME" }))
1347 .await
1348 .unwrap();
1349 assert!(!result.is_error, "Bash should succeed");
1350
1351 let expected = tmp.path().to_string_lossy().to_string();
1352 assert!(
1353 result.content.trim().contains(&expected),
1354 "HOME should be set to cwd ({}), got: {}",
1355 expected,
1356 result.content.trim()
1357 );
1358 }
1359
1360 #[tokio::test]
1361 async fn bash_tilde_resolves_to_cwd() {
1362 let (tmp, executor) = setup();
1363
1364 std::fs::write(tmp.path().join("marker.txt"), "found").unwrap();
1366
1367 let result = executor
1368 .execute("Bash", json!({ "command": "cat ~/marker.txt" }))
1369 .await
1370 .unwrap();
1371 assert!(
1372 !result.is_error,
1373 "Should read file via ~: {}",
1374 result.content
1375 );
1376 assert!(result.content.contains("found"), "~ should resolve to cwd");
1377 }
1378
1379 #[tokio::test]
1380 async fn bash_env_blocklist_strips_vars() {
1381 let tmp = TempDir::new().unwrap();
1382 unsafe {
1384 std::env::set_var("STARPOD_TEST_SECRET", "leaked");
1385 }
1386
1387 let executor = ToolExecutor::new(tmp.path().to_path_buf())
1388 .with_env_blocklist(vec!["STARPOD_TEST_SECRET".to_string()]);
1389
1390 let result = executor
1391 .execute(
1392 "Bash",
1393 json!({ "command": "echo \"val=${STARPOD_TEST_SECRET}\"" }),
1394 )
1395 .await
1396 .unwrap();
1397 assert!(!result.is_error);
1398 assert_eq!(
1399 result.content.trim(),
1400 "val=",
1401 "Blocked env var should not be visible to child process"
1402 );
1403
1404 std::env::remove_var("STARPOD_TEST_SECRET");
1406 }
1407
1408 #[tokio::test]
1409 async fn bash_env_blocklist_does_not_affect_other_vars() {
1410 let tmp = TempDir::new().unwrap();
1411 unsafe {
1412 std::env::set_var("STARPOD_TEST_ALLOWED", "visible");
1413 std::env::set_var("STARPOD_TEST_BLOCKED", "hidden");
1414 }
1415
1416 let executor = ToolExecutor::new(tmp.path().to_path_buf())
1417 .with_env_blocklist(vec!["STARPOD_TEST_BLOCKED".to_string()]);
1418
1419 let result = executor
1420 .execute("Bash", json!({ "command": "echo $STARPOD_TEST_ALLOWED" }))
1421 .await
1422 .unwrap();
1423 assert!(
1424 result.content.contains("visible"),
1425 "Non-blocked vars should still be inherited"
1426 );
1427
1428 std::env::remove_var("STARPOD_TEST_ALLOWED");
1430 std::env::remove_var("STARPOD_TEST_BLOCKED");
1431 }
1432}