telos_agent/tools/builtin/
grep.rs1use 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
17pub 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 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 let canonical_path = match canonicalize_within_cwd(&context.cwd, &path).await {
91 Ok(p) => p,
92 Err(_) => continue,
93 };
94 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 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}