Skip to main content

telos_agent/tools/builtin/
grep.rs

1//! `grep` tool — substring search over files matched by a glob.
2//!
3//! Matches are *literal* substring searches (no regex). For each hit we emit
4//! the path, 1-indexed line number, and the matched line — enough context for
5//! the model to follow up with [`FileReadTool`].
6
7use async_trait::async_trait;
8use serde_json::{Value, json};
9
10use std::path::{Path, PathBuf};
11
12use crate::error::AgentError;
13use crate::tools::api::{Tool, ToolContext, ToolDefinition, ToolOutput};
14
15use super::{canonicalize_within_cwd, display_relative, required_string};
16
17/// Built-in grep tool. Read-only; safe to run concurrently.
18pub struct GrepTool;
19
20#[async_trait]
21impl Tool for GrepTool {
22    fn definition(&self) -> ToolDefinition {
23        ToolDefinition {
24            name: "Grep".into(),
25            description: "Search UTF-8 files for a literal text pattern.".into(),
26            input_schema: json!({
27                "type": "object",
28                "properties": {
29                    "pattern": { "type": "string" },
30                    "glob": { "type": "string" },
31                    "max_results": { "type": "integer" }
32                },
33                "required": ["pattern"]
34            }),
35        }
36    }
37
38    fn prompt_text(&self) -> Option<&'static str> {
39        Some(
40            "Use Grep for literal substring search in files (not regex). Results include path, line number, and matched line. \
41Use `glob` to scope; absolute globs must stay under the working directory.",
42        )
43    }
44
45    async fn validate(&self, arguments: &Value, _context: &ToolContext) -> Result<(), AgentError> {
46        required_string(arguments, "pattern").map(|_| ())
47    }
48
49    fn is_concurrency_safe(&self, _arguments: &Value) -> bool {
50        true
51    }
52
53    async fn invoke(
54        &self,
55        arguments: Value,
56        context: ToolContext,
57    ) -> Result<ToolOutput, AgentError> {
58        let pattern = required_string(&arguments, "pattern")?.to_string();
59        // Default to a recursive glob so plain "grep for X" works out of the box.
60        let file_glob = arguments.get("glob").and_then(|value| value.as_str()).unwrap_or("**/*");
61        let max_results =
62            arguments.get("max_results").and_then(|value| value.as_u64()).unwrap_or(200) as usize;
63        let full_pattern = if Path::new(file_glob).is_absolute() {
64            let anchor = absolute_glob_anchor(file_glob);
65            canonicalize_within_cwd(&context.cwd, &anchor).await.map_err(|_| {
66                AgentError::PermissionDenied(format!(
67                    "absolute glob pattern must stay under cwd: {file_glob}"
68                ))
69            })?;
70            file_glob.to_string()
71        } else {
72            context.cwd.join(file_glob).to_string_lossy().to_string()
73        };
74        let mut results = Vec::new();
75        for entry in
76            glob::glob(&full_pattern).map_err(|err| AgentError::Validation(err.to_string()))?
77        {
78            if results.len() >= max_results {
79                break;
80            }
81            let Ok(path) = entry else {
82                continue;
83            };
84            if !path.is_file() {
85                continue;
86            }
87            // Defensive: `../foo` style globs can still resolve outside cwd, and
88            // a symlink inside cwd may point outside. Follow symlinks and reject
89            // any file whose canonical location is not under cwd.
90            let canonical_path = match canonicalize_within_cwd(&context.cwd, &path).await {
91                Ok(p) => p,
92                Err(_) => continue,
93            };
94            // Skip files that exceed the configured read budget.
95            if let Ok(metadata) = tokio::fs::metadata(&canonical_path).await
96                && metadata.len() > context.max_file_read_bytes as u64
97            {
98                continue;
99            }
100            // Silently skip files we can't read as UTF-8 (binary, permissions, etc.).
101            let Ok(content) = tokio::fs::read_to_string(&canonical_path).await else {
102                continue;
103            };
104            for (idx, line) in content.lines().enumerate() {
105                if line.contains(&pattern) {
106                    results.push(json!({
107                        "path": display_relative(&context.cwd, &path),
108                        "line": idx + 1,
109                        "text": line,
110                    }));
111                    if results.len() >= max_results {
112                        break;
113                    }
114                }
115            }
116        }
117        Ok(ToolOutput::json(json!({ "matches": results })))
118    }
119}
120
121fn absolute_glob_anchor(pattern: &str) -> PathBuf {
122    let mut anchor = PathBuf::new();
123    for component in Path::new(pattern).components() {
124        let text = component.as_os_str().to_string_lossy();
125        if text.contains('*') || text.contains('?') || text.contains('[') {
126            break;
127        }
128        anchor.push(component.as_os_str());
129    }
130    anchor
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136    use serde_json::json;
137    use std::collections::HashMap;
138
139    fn ctx(cwd: std::path::PathBuf) -> ToolContext {
140        ToolContext {
141            session_id: "test".into(),
142            turn_id: 1,
143            tool_call_id: None,
144            cwd,
145            env: HashMap::new(),
146            messages: std::sync::Arc::new(vec![]),
147            progress: None,
148            read_file_state: std::sync::Arc::new(tokio::sync::Mutex::new(HashMap::new())),
149            timeout: None,
150            max_file_read_bytes: usize::MAX,
151        }
152    }
153
154    #[tokio::test]
155    async fn rejects_absolute_glob() {
156        let dir = std::env::temp_dir().join("tiny_agent_grep_absolute_cwd_test");
157        let outside = std::env::temp_dir().join("tiny_agent_grep_absolute_outside_test");
158        let _ = std::fs::remove_dir_all(&dir);
159        let _ = std::fs::remove_dir_all(&outside);
160        std::fs::create_dir_all(&dir).unwrap();
161        std::fs::create_dir_all(&outside).unwrap();
162
163        let tool = GrepTool;
164        let file_glob = outside.join("*").to_string_lossy().to_string();
165        let result =
166            tool.invoke(json!({ "pattern": "root", "glob": file_glob }), ctx(dir.clone())).await;
167        assert!(matches!(result, Err(AgentError::PermissionDenied(_))));
168
169        let _ = std::fs::remove_dir_all(&dir);
170        let _ = std::fs::remove_dir_all(&outside);
171    }
172
173    #[tokio::test]
174    async fn accepts_absolute_glob_under_cwd() {
175        let dir = std::env::temp_dir().join("tiny_agent_grep_absolute_under_cwd_test");
176        let _ = std::fs::remove_dir_all(&dir);
177        std::fs::create_dir_all(&dir).unwrap();
178        std::fs::write(dir.join("sample.txt"), "alpha\nneedle\n").unwrap();
179
180        let tool = GrepTool;
181        let file_glob = dir.join("*.txt").to_string_lossy().to_string();
182        let output = tool
183            .invoke(json!({ "pattern": "needle", "glob": file_glob }), ctx(dir.clone()))
184            .await
185            .unwrap();
186        let matches = output.content["matches"].as_array().unwrap();
187        assert_eq!(matches.len(), 1);
188        assert_eq!(matches[0]["path"], "sample.txt");
189        assert_eq!(matches[0]["line"], 2);
190
191        let _ = std::fs::remove_dir_all(&dir);
192    }
193
194    #[tokio::test]
195    async fn skips_files_outside_cwd() {
196        let dir = std::env::temp_dir().join("tiny_agent_grep_escape_test");
197        let _ = std::fs::remove_dir_all(&dir);
198        std::fs::create_dir_all(dir.join("sub")).unwrap();
199        std::fs::write(dir.join("sub").join("inside.txt"), "match").unwrap();
200        std::fs::write(dir.join("outside.txt"), "match").unwrap();
201
202        let tool = GrepTool;
203        let output = tool
204            .invoke(json!({ "pattern": "match", "glob": "../**/*.txt" }), ctx(dir.join("sub")))
205            .await
206            .unwrap();
207        let matches = output.content["matches"].as_array().unwrap();
208        assert!(matches.iter().all(|m| !m["path"].as_str().unwrap().contains("outside")));
209
210        let _ = std::fs::remove_dir_all(&dir);
211    }
212
213    #[cfg(unix)]
214    #[tokio::test]
215    async fn rejects_symlink_escape() {
216        let dir = std::env::temp_dir().join("tiny_agent_grep_symlink_test");
217        let outside = std::env::temp_dir().join("tiny_agent_grep_symlink_outside");
218        let _ = std::fs::remove_dir_all(&dir);
219        let _ = std::fs::remove_dir_all(&outside);
220        std::fs::create_dir_all(&dir).unwrap();
221        std::fs::create_dir_all(&outside).unwrap();
222        std::fs::write(outside.join("secret.txt"), "match").unwrap();
223        std::os::unix::fs::symlink(outside.join("secret.txt"), dir.join("link.txt")).unwrap();
224
225        let tool = GrepTool;
226        let output = tool.invoke(json!({ "pattern": "match" }), ctx(dir.clone())).await.unwrap();
227        let matches = output.content["matches"].as_array().unwrap();
228        assert!(matches.is_empty(), "symlink escape should produce no matches");
229
230        let _ = std::fs::remove_dir_all(&dir);
231        let _ = std::fs::remove_dir_all(&outside);
232    }
233}