Skip to main content

agent_sdk/tools/
executor.rs

1//! Built-in tool execution engine.
2//!
3//! Provides [`ToolExecutor`] which can run the core built-in tools:
4//! Read, Write, Edit, Bash, Glob, and Grep.
5
6use 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/// Result of executing a built-in tool.
23#[derive(Debug, Clone)]
24pub struct ToolResult {
25    /// The textual output of the tool.
26    pub content: String,
27    /// Whether the result represents an error condition.
28    pub is_error: bool,
29    /// Optional rich content (e.g. image blocks) to send directly as the API
30    /// tool-result `content` field. When set, this takes precedence over `content`.
31    pub raw_content: Option<serde_json::Value>,
32}
33
34impl ToolResult {
35    /// Create a successful result.
36    fn ok(content: String) -> Self {
37        Self {
38            content,
39            is_error: false,
40            raw_content: None,
41        }
42    }
43
44    /// Create an error result.
45    fn err(content: String) -> Self {
46        Self {
47            content,
48            is_error: true,
49            raw_content: None,
50        }
51    }
52}
53
54/// Non-blocking drain of a pipe handle. Reads all immediately available data
55/// without waiting for EOF — background processes that inherited the pipe fd
56/// won't cause us to block.
57async 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, // EOF — pipe fully closed
66            Ok(Ok(n)) => buf.extend_from_slice(&chunk[..n]),
67            Ok(Err(_)) => break, // read error
68            Err(_) => break,     // no data within 10ms — pipe held by bg process
69        }
70    }
71    buf
72}
73
74/// Executes built-in tools (Read, Write, Edit, Bash, Glob, Grep).
75pub struct ToolExecutor {
76    /// Working directory for relative path resolution and command execution.
77    cwd: PathBuf,
78    /// Optional path boundary. When `Some`, file-based tools (Read, Write,
79    /// Edit, Glob, Grep) will reject paths that fall outside the allowed
80    /// directories. This helps the model stay within the intended working
81    /// area — it is NOT an OS-level security sandbox (Bash is not restricted).
82    boundary: Option<PathBoundary>,
83    /// Environment variable names to strip from child processes (Bash).
84    /// Prevents the agent from reading sensitive keys via shell commands.
85    env_blocklist: Vec<String>,
86}
87
88/// Guides file-based tools to stay within a set of allowed directory trees.
89///
90/// This is a model-assistance guardrail, not a security sandbox — the Bash
91/// tool is unrestricted and can access any path the process can reach.
92/// For true isolation, OS-level sandboxing (sandbox-exec, bwrap) is needed.
93///
94/// Boundary directories are canonicalized once at construction time so that
95/// every subsequent `check()` is a cheap prefix comparison.
96struct PathBoundary {
97    /// Canonicalized allowed directories (cwd + additional).
98    allowed: Vec<PathBuf>,
99}
100
101impl PathBoundary {
102    /// Build a sandbox from the cwd (always allowed) plus additional directories.
103    fn new(cwd: &Path, additional: &[PathBuf]) -> Self {
104        let mut allowed = Vec::with_capacity(1 + additional.len());
105
106        // Canonicalize once; if a directory doesn't exist yet we still keep
107        // the raw path so that the sandbox doesn't silently become empty.
108        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    /// Check whether `path` falls inside any allowed directory.
121    ///
122    /// Strategy:
123    /// 1. Try `canonicalize()` on the full path (works for existing files/symlinks).
124    /// 2. If the file doesn't exist yet (Write/Edit), canonicalize the nearest
125    ///    existing ancestor and append the remaining components.
126    /// 3. If nothing on the path exists at all, **deny** — we refuse to guess.
127    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    /// Produce a fully-resolved, `..`-free path without requiring every
143    /// component to exist on disk.
144    ///
145    /// Walks up the path until it finds an ancestor that *does* exist,
146    /// canonicalizes that part (resolving symlinks), then re-appends the
147    /// remaining normal components (rejecting any leftover `..`).
148    fn normalize(path: &Path) -> std::result::Result<PathBuf, ToolResult> {
149        // Fast path: the full path already exists.
150        if let Ok(canon) = path.canonicalize() {
151            return Ok(canon);
152        }
153
154        // Walk up to find the deepest existing ancestor.
155        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                // Re-append remaining components — only Normal segments allowed.
165                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        // Nothing on the path exists — deny.
185        Err(ToolResult::err(format!(
186            "Access denied: cannot resolve {}",
187            path.display()
188        )))
189    }
190}
191
192impl ToolExecutor {
193    /// Create a new executor rooted at the given working directory (no path boundary).
194    pub fn new(cwd: PathBuf) -> Self {
195        Self {
196            cwd,
197            boundary: None,
198            env_blocklist: Vec::new(),
199        }
200    }
201
202    /// Create a new executor with a path boundary.
203    /// File-based tools will only operate within `cwd` and the `additional` directories.
204    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    /// Set environment variable names to strip from child processes.
214    pub fn with_env_blocklist(mut self, blocklist: Vec<String>) -> Self {
215        self.env_blocklist = blocklist;
216        self
217    }
218
219    /// Dispatch a tool call by name, deserializing `input` into the appropriate typed input.
220    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(&params).await
227            }
228            "Write" => {
229                let params: FileWriteInput = serde_json::from_value(input)?;
230                self.execute_write(&params).await
231            }
232            "Edit" => {
233                let params: FileEditInput = serde_json::from_value(input)?;
234                self.execute_edit(&params).await
235            }
236            "Bash" => {
237                let params: BashInput = serde_json::from_value(input)?;
238                self.execute_bash(&params).await
239            }
240            "Glob" => {
241                let params: GlobInput = serde_json::from_value(input)?;
242                self.execute_glob(&params).await
243            }
244            "Grep" => {
245                let params: GrepInput = serde_json::from_value(input)?;
246                self.execute_grep(&params).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    // ── Helpers ──────────────────────────────────────────────────────────
255
256    /// Resolve a path (relative → absolute) and validate it against the boundary.
257    /// Returns the resolved `PathBuf` or an access-denied `ToolResult`.
258    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    // ── Read ────────────────────────────────────────────────────────────
274
275    /// Read a file's contents, optionally slicing by line offset and limit.
276    /// Output uses `cat -n` style (line-number prefixed) formatting.
277    /// Image files (png, jpg, gif, webp) are returned as base64 image content
278    /// blocks so the model can see them directly.
279    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        // Check if the file is an image by extension.
286        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        // Format like `cat -n`: right-aligned line numbers followed by a tab and the line.
328        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; // 1-based
332            output.push_str(&format!("{line_no:>width$}\t{line}\n", width = width));
333        }
334
335        Ok(ToolResult::ok(output))
336    }
337
338    /// Read an image file and return it as a base64 image content block.
339    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        // Cap at 20 MB to avoid blowing up context.
351        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    // ── Write ───────────────────────────────────────────────────────────
379
380    /// Write content to a file, creating parent directories as needed.
381    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        // Ensure parent directories exist.
388        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    // ── Edit ────────────────────────────────────────────────────────────
410
411    /// Perform an exact string replacement in a file.
412    ///
413    /// - If `old_string` is not found, returns an error result.
414    /// - If `old_string` appears more than once and `replace_all` is not set, returns an error
415    ///   asking for more context to make the match unique.
416    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            // Replace only the first (and only) occurrence.
453            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    // ── Bash ────────────────────────────────────────────────────────────
476
477    /// Run a shell command via `/bin/bash -c`, capturing stdout and stderr.
478    /// Supports an optional timeout (in milliseconds) and background execution.
479    async fn execute_bash(&self, input: &BashInput) -> Result<ToolResult> {
480        // Background mode: spawn and return immediately with the PID.
481        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        // Take stdout/stderr handles before waiting.
530        let stdout_handle = child.stdout.take();
531        let stderr_handle = child.stderr.take();
532
533        // Wait for the shell process under the configured timeout.
534        let wait_result = tokio::time::timeout(timeout_dur, child.wait()).await;
535
536        match wait_result {
537            Ok(Ok(status)) => {
538                // Shell exited — drain whatever data is already buffered in
539                // the pipe without blocking. Background processes (`cmd &`)
540                // may hold inherited pipe fds, so we must not wait for EOF.
541                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                // Timeout – attempt to kill the child.
572                let _ = child.kill().await;
573                Ok(ToolResult::err(format!(
574                    "Command timed out after {timeout_ms}ms"
575                )))
576            }
577        }
578    }
579
580    // ── Glob ────────────────────────────────────────────────────────────
581
582    /// Find files matching a glob pattern. Searches from the provided `path` or the cwd.
583    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        // Glob matching is CPU-bound; run on the blocking pool.
596        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    // ── Grep ────────────────────────────────────────────────────────────
629
630    /// Search file contents using regex, with support for multiple output modes,
631    /// context lines, case insensitivity, line numbers, head limit, and offset.
632    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        // Grep is CPU-intensive; run on the blocking pool.
643        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
651// ── Grep implementation (synchronous, for spawn_blocking) ───────────────
652
653/// File extension mapping for the `type` filter (mirrors ripgrep's type system).
654fn 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
684/// Check whether a file path matches the glob filter or type filter.
685fn 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
709/// Collect all regular files under `dir`, respecting hidden-directory skipping.
710fn 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        // Skip hidden directories and common noise.
728        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
740/// Synchronous grep implementation.
741fn 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    // Context lines: -C is an alias, prefer `context`, then `-C`, then individual -A/-B.
751    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    // Build regex.
759    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    // Determine search root.
766    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    // Compile optional filters.
779    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    // Collect files to search.
789    let files = if search_path.is_file() {
790        vec![search_path.clone()]
791    } else {
792        walk_files(&search_path)
793    };
794
795    // Filter files.
796    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        // Find which lines match.
886        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        // Build set of lines to display (matches + context).
898        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        // Emit grouped output with separators between non-contiguous ranges.
910        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
945/// Apply offset (skip) and head_limit (take) to a list of entries.
946fn 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    // ── Read: text files ────────────────────────────────────────────────
968
969    #[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        // Offset 1 means skip first line, so we get lines 2-3 (b, c)
1002        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    // ── Read: image files ───────────────────────────────────────────────
1023
1024    #[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        // Verify the data round-trips through base64
1045        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        // Write some bytes with .jpg extension — we don't need a valid JPEG,
1058        // just enough to test the extension detection and base64 encoding.
1059        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    // ── ToolResult ──────────────────────────────────────────────────────
1157
1158    #[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    // ── Path sandboxing ────────────────────────────────────────────────
1173
1174    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        // Default executor (no allowed_dirs) should not restrict paths.
1269        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        // Create a file outside via ../ traversal
1285        let outside = TempDir::new().unwrap();
1286        let secret = outside.path().join("secret.txt");
1287        std::fs::write(&secret, "sensitive").unwrap();
1288
1289        // Try to read via a ../../../ path rooted inside the project
1290        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        // Create a symlink inside project pointing outside
1329        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        // Create a file in the cwd
1365        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        // Set a test env var in the process
1383        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        // Cleanup
1405        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        // Cleanup
1429        std::env::remove_var("STARPOD_TEST_ALLOWED");
1430        std::env::remove_var("STARPOD_TEST_BLOCKED");
1431    }
1432}