claude_agent/tools/
glob.rs

1//! Glob tool - file pattern matching with sandbox validation.
2
3use async_trait::async_trait;
4use schemars::JsonSchema;
5use serde::Deserialize;
6
7use super::SchemaTool;
8use super::context::ExecutionContext;
9use crate::types::ToolResult;
10
11/// Input for the Glob tool
12#[derive(Debug, Deserialize, JsonSchema)]
13#[schemars(deny_unknown_fields)]
14pub struct GlobInput {
15    /// The glob pattern to match files against
16    pub pattern: String,
17    /// The directory to search in. If not specified, the current working directory will be used.
18    /// IMPORTANT: Omit this field to use the default directory. DO NOT enter "undefined" or "null" -
19    /// simply omit it for the default behavior. Must be a valid directory path if provided.
20    #[serde(default)]
21    pub path: Option<String>,
22}
23
24#[derive(Debug, Clone, Copy, Default)]
25pub struct GlobTool;
26
27#[async_trait]
28impl SchemaTool for GlobTool {
29    type Input = GlobInput;
30
31    const NAME: &'static str = "Glob";
32    const DESCRIPTION: &'static str = r#"- Fast file pattern matching tool that works with any codebase size
33- Supports glob patterns like "**/*.js" or "src/**/*.ts"
34- Returns matching file paths sorted by modification time
35- Use this tool when you need to find files by name patterns
36- When you are doing an open ended search that may require multiple rounds of globbing and grepping, use the Task tool instead
37- You can call multiple tools in a single response. It is always better to speculatively perform multiple searches in parallel if they are potentially useful."#;
38
39    async fn handle(&self, input: GlobInput, context: &ExecutionContext) -> ToolResult {
40        let base_path = match context.try_resolve_or_root_for(Self::NAME, input.path.as_deref()) {
41            Ok(path) => path,
42            Err(e) => return e,
43        };
44
45        let full_pattern = base_path.join(&input.pattern);
46        let pattern_str = full_pattern.to_string_lossy().to_string();
47
48        let glob_result = tokio::task::spawn_blocking(move || {
49            glob::glob(&pattern_str).map(|paths| {
50                paths
51                    .filter_map(|r| r.ok())
52                    .filter_map(|p| {
53                        std::fs::canonicalize(&p).ok().and_then(|canonical| {
54                            canonical
55                                .metadata()
56                                .ok()
57                                .and_then(|m| m.modified().ok())
58                                .map(|mtime| (canonical, mtime))
59                        })
60                    })
61                    .collect::<Vec<_>>()
62            })
63        })
64        .await;
65
66        let all_entries = match glob_result {
67            Ok(Ok(entries)) => entries,
68            Ok(Err(e)) => return ToolResult::error(format!("Invalid pattern: {}", e)),
69            Err(e) => return ToolResult::error(format!("Glob task failed: {}", e)),
70        };
71
72        let mut entries: Vec<_> = all_entries
73            .into_iter()
74            .filter(|(p, _)| context.is_within(p))
75            .collect();
76
77        if entries.is_empty() {
78            return ToolResult::success("No files matched the pattern");
79        }
80
81        entries.sort_by(|a, b| b.1.cmp(&a.1));
82
83        let output: Vec<String> = entries
84            .into_iter()
85            .map(|(p, _)| p.display().to_string())
86            .collect();
87
88        ToolResult::success(output.join("\n"))
89    }
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95    use crate::tools::Tool;
96    use crate::types::ToolOutput;
97    use tempfile::tempdir;
98    use tokio::fs;
99
100    #[tokio::test]
101    async fn test_glob_pattern() {
102        let dir = tempdir().unwrap();
103        let root = std::fs::canonicalize(dir.path()).unwrap();
104        fs::write(root.join("test1.txt"), "").await.unwrap();
105        fs::write(root.join("test2.txt"), "").await.unwrap();
106        fs::write(root.join("other.rs"), "").await.unwrap();
107
108        let test_context = ExecutionContext::from_path(&root).unwrap();
109        let tool = GlobTool;
110
111        let result = tool
112            .execute(serde_json::json!({"pattern": "*.txt"}), &test_context)
113            .await;
114
115        match &result.output {
116            ToolOutput::Success(content) => {
117                assert!(content.contains("test1.txt"));
118                assert!(content.contains("test2.txt"));
119                assert!(!content.contains("other.rs"));
120            }
121            _ => panic!("Expected success"),
122        }
123    }
124
125    #[tokio::test]
126    async fn test_glob_recursive_pattern() {
127        let dir = tempdir().unwrap();
128        let root = std::fs::canonicalize(dir.path()).unwrap();
129
130        let subdir = root.join("src");
131        fs::create_dir_all(&subdir).await.unwrap();
132        fs::write(root.join("main.rs"), "fn main() {}")
133            .await
134            .unwrap();
135        fs::write(subdir.join("lib.rs"), "pub mod lib;")
136            .await
137            .unwrap();
138        fs::write(subdir.join("utils.rs"), "pub fn util() {}")
139            .await
140            .unwrap();
141
142        let test_context = ExecutionContext::from_path(&root).unwrap();
143        let tool = GlobTool;
144
145        let result = tool
146            .execute(serde_json::json!({"pattern": "**/*.rs"}), &test_context)
147            .await;
148
149        match &result.output {
150            ToolOutput::Success(content) => {
151                assert!(content.contains("main.rs"));
152                assert!(content.contains("lib.rs"));
153                assert!(content.contains("utils.rs"));
154            }
155            _ => panic!("Expected success"),
156        }
157    }
158
159    #[tokio::test]
160    async fn test_glob_no_matches() {
161        let dir = tempdir().unwrap();
162        let root = std::fs::canonicalize(dir.path()).unwrap();
163        fs::write(root.join("test.txt"), "").await.unwrap();
164
165        let test_context = ExecutionContext::from_path(&root).unwrap();
166        let tool = GlobTool;
167
168        let result = tool
169            .execute(serde_json::json!({"pattern": "*.py"}), &test_context)
170            .await;
171
172        match &result.output {
173            ToolOutput::Success(content) => {
174                assert!(content.contains("No files matched"));
175            }
176            _ => panic!("Expected success with no matches message"),
177        }
178    }
179
180    #[tokio::test]
181    async fn test_glob_with_path() {
182        let dir = tempdir().unwrap();
183        let root = std::fs::canonicalize(dir.path()).unwrap();
184
185        let subdir = root.join("nested");
186        fs::create_dir_all(&subdir).await.unwrap();
187        fs::write(root.join("root.txt"), "").await.unwrap();
188        fs::write(subdir.join("nested.txt"), "").await.unwrap();
189
190        let test_context = ExecutionContext::from_path(&root).unwrap();
191        let tool = GlobTool;
192
193        let result = tool
194            .execute(
195                serde_json::json!({"pattern": "*.txt", "path": "nested"}),
196                &test_context,
197            )
198            .await;
199
200        match &result.output {
201            ToolOutput::Success(content) => {
202                assert!(content.contains("nested.txt"));
203                assert!(!content.contains("root.txt"));
204            }
205            _ => panic!("Expected success"),
206        }
207    }
208
209    #[tokio::test]
210    async fn test_glob_invalid_pattern() {
211        let dir = tempdir().unwrap();
212        let root = std::fs::canonicalize(dir.path()).unwrap();
213
214        let test_context = ExecutionContext::from_path(&root).unwrap();
215        let tool = GlobTool;
216
217        let result = tool
218            .execute(serde_json::json!({"pattern": "[invalid"}), &test_context)
219            .await;
220
221        match &result.output {
222            ToolOutput::Error(e) => {
223                assert!(e.to_string().contains("Invalid pattern"));
224            }
225            _ => panic!("Expected error for invalid pattern"),
226        }
227    }
228
229    #[tokio::test]
230    async fn test_glob_sorted_by_mtime() {
231        let dir = tempdir().unwrap();
232        let root = std::fs::canonicalize(dir.path()).unwrap();
233
234        fs::write(root.join("old.txt"), "old").await.unwrap();
235        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
236        fs::write(root.join("new.txt"), "new").await.unwrap();
237
238        let test_context = ExecutionContext::from_path(&root).unwrap();
239        let tool = GlobTool;
240
241        let result = tool
242            .execute(serde_json::json!({"pattern": "*.txt"}), &test_context)
243            .await;
244
245        match &result.output {
246            ToolOutput::Success(content) => {
247                let new_pos = content.find("new.txt").unwrap();
248                let old_pos = content.find("old.txt").unwrap();
249                assert!(new_pos < old_pos, "Newer file should appear first");
250            }
251            _ => panic!("Expected success"),
252        }
253    }
254
255    #[test]
256    fn test_glob_input_parsing() {
257        let input: GlobInput = serde_json::from_value(serde_json::json!({
258            "pattern": "**/*.rs",
259            "path": "src"
260        }))
261        .unwrap();
262        assert_eq!(input.pattern, "**/*.rs");
263        assert_eq!(input.path, Some("src".to_string()));
264    }
265
266    #[tokio::test]
267    async fn test_glob_path_traversal_blocked() {
268        // Create parent and working directories
269        let parent = tempdir().unwrap();
270        let parent_path = std::fs::canonicalize(parent.path()).unwrap();
271
272        let working_dir = parent_path.join("sandbox");
273        std::fs::create_dir_all(&working_dir).unwrap();
274        let sandbox_path = std::fs::canonicalize(&working_dir).unwrap();
275
276        // Create files
277        std::fs::write(parent_path.join("secret.txt"), "SECRET").unwrap();
278        std::fs::write(sandbox_path.join("allowed.txt"), "allowed").unwrap();
279
280        // Context with sandbox_path as root
281        let test_context = ExecutionContext::from_path(&sandbox_path).unwrap();
282        let tool = GlobTool;
283
284        // Try to access parent directory via ../*.txt
285        let result = tool
286            .execute(serde_json::json!({"pattern": "../*.txt"}), &test_context)
287            .await;
288
289        match &result.output {
290            ToolOutput::Success(content) => {
291                // Should NOT find secret.txt (outside sandbox)
292                assert!(
293                    !content.contains("secret.txt"),
294                    "Path traversal should be blocked! Found: {}",
295                    content
296                );
297            }
298            ToolOutput::Error(_) => {
299                // Error is also acceptable
300            }
301            _ => panic!("Unexpected result"),
302        }
303    }
304}