Skip to main content

agent_sdk/primitive_tools/
glob.rs

1use crate::{Environment, PrimitiveToolName, Tool, ToolContext, ToolResult, ToolTier};
2use anyhow::{Context, Result};
3use serde::Deserialize;
4use serde_json::{Value, json};
5use std::sync::Arc;
6
7use super::PrimitiveToolContext;
8
9/// Tool for finding files by glob pattern
10pub struct GlobTool<E: Environment> {
11    ctx: PrimitiveToolContext<E>,
12}
13
14impl<E: Environment> GlobTool<E> {
15    #[must_use]
16    pub const fn new(environment: Arc<E>, capabilities: crate::AgentCapabilities) -> Self {
17        Self {
18            ctx: PrimitiveToolContext::new(environment, capabilities),
19        }
20    }
21}
22
23#[derive(Debug, Deserialize)]
24struct GlobInput {
25    /// Glob pattern to match files (e.g., "**/*.rs", "src/*.ts").
26    /// Relative patterns are resolved against `path` (or the environment root);
27    /// an absolute pattern (starting with `/`) is used as-is.
28    pattern: String,
29    /// Optional directory to search in (defaults to environment root)
30    #[serde(default)]
31    path: Option<String>,
32}
33
34impl<E: Environment + 'static, Ctx: Send + Sync + 'static> Tool<Ctx> for GlobTool<E> {
35    type Name = PrimitiveToolName;
36
37    fn name(&self) -> PrimitiveToolName {
38        PrimitiveToolName::Glob
39    }
40
41    fn display_name(&self) -> &'static str {
42        "Find Files"
43    }
44
45    fn description(&self) -> &'static str {
46        "Find files matching a glob pattern. Supports ** for recursive matching."
47    }
48
49    fn tier(&self) -> ToolTier {
50        ToolTier::Observe
51    }
52
53    fn input_schema(&self) -> Value {
54        json!({
55            "type": "object",
56            "properties": {
57                "pattern": {
58                    "type": "string",
59                    "description": "Glob pattern to match files (e.g., '**/*.rs', 'src/**/*.ts'). Relative to 'path' (or the environment root); an absolute pattern starting with '/' is used as-is. Must not contain '..' segments."
60                },
61                "path": {
62                    "type": "string",
63                    "description": "Directory to search in. Defaults to environment root."
64                }
65            },
66            "required": ["pattern"]
67        })
68    }
69
70    async fn execute(&self, _ctx: &ToolContext<Ctx>, input: Value) -> Result<ToolResult> {
71        let input: GlobInput = GlobInput::deserialize(&input)
72            .with_context(|| format!("Invalid input for glob tool: {input}"))?;
73
74        // The raw pattern is concatenated onto the (normalized) base and passed
75        // straight to `Environment::glob`, so `..` segments could escape the
76        // search root in a custom Environment that does not re-normalize. Reject
77        // them up front rather than relying solely on the per-result filter.
78        if pattern_has_parent_segment(&input.pattern) {
79            return Ok(ToolResult::error(
80                "pattern must not contain '..' path segments; use the 'path' parameter to choose a search directory",
81            ));
82        }
83
84        // Build the full pattern.
85        //
86        // - An absolute pattern (leading `/`) is used as-is; each result is
87        //   still gated by the per-path `check_read` filter below.
88        // - A relative pattern is joined onto the resolved base. The base is a
89        //   literal filesystem path, but `glob` uses `/` as its separator on
90        //   every platform and treats `\` as an escape character — so a Windows
91        //   base such as `C:\Users\…` would be parsed as escape sequences.
92        //   Normalise the base's separators to `/` before joining (a no-op on
93        //   Unix); the user's `pattern` is left as-is so its glob
94        //   metacharacters keep their meaning.
95        //
96        // NOTE: `Environment::glob`/`grep` implementations MUST enforce read
97        // permissions per traversed path; the root-only capability check plus
98        // the per-result filter here are defense-in-depth, not a substitute.
99        let pattern = if input.pattern.starts_with('/') {
100            input.pattern.clone()
101        } else if let Some(ref base_path) = input.path {
102            let base = self
103                .ctx
104                .environment
105                .resolve_path(base_path)
106                .replace('\\', "/");
107            format!("{}/{}", base.trim_end_matches('/'), input.pattern)
108        } else {
109            let root = self.ctx.environment.root().replace('\\', "/");
110            format!("{}/{}", root.trim_end_matches('/'), input.pattern)
111        };
112
113        // Check read capability for the search path
114        let search_path = input.path.as_ref().map_or_else(
115            || self.ctx.environment.root().to_string(),
116            |p| self.ctx.environment.resolve_path(p),
117        );
118
119        if let Err(reason) = self.ctx.capabilities.check_read(&search_path) {
120            return Ok(ToolResult::error(format!(
121                "Permission denied: cannot search in '{search_path}': {reason}"
122            )));
123        }
124
125        // Execute glob. A malformed pattern is the model's own input, so report
126        // it as a correctable tool error rather than an infrastructure failure.
127        let matches = match self.ctx.environment.glob(&pattern).await {
128            Ok(matches) => matches,
129            Err(err) => {
130                return Ok(ToolResult::error(format!(
131                    "Invalid glob pattern '{}': {err:#}",
132                    input.pattern
133                )));
134            }
135        };
136
137        // Filter out files that the agent can't read
138        let accessible_matches: Vec<_> = matches
139            .into_iter()
140            .filter(|path| self.ctx.capabilities.check_read(path).is_ok())
141            .collect();
142
143        if accessible_matches.is_empty() {
144            return Ok(ToolResult::success(format!(
145                "No files found matching pattern '{}'",
146                input.pattern
147            )));
148        }
149
150        let count = accessible_matches.len();
151        let output = if count > 100 {
152            format!(
153                "Found {count} files (showing first 100):\n{}",
154                accessible_matches[..100].join("\n")
155            )
156        } else {
157            format!("Found {count} files:\n{}", accessible_matches.join("\n"))
158        };
159
160        Ok(ToolResult::success(output))
161    }
162}
163
164/// Returns true if any `/`-separated component of the pattern is exactly `..`.
165fn pattern_has_parent_segment(pattern: &str) -> bool {
166    pattern.split('/').any(|segment| segment == "..")
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172    use crate::{AgentCapabilities, InMemoryFileSystem};
173
174    fn create_test_tool(
175        fs: Arc<InMemoryFileSystem>,
176        capabilities: AgentCapabilities,
177    ) -> GlobTool<InMemoryFileSystem> {
178        GlobTool::new(fs, capabilities)
179    }
180
181    fn tool_ctx() -> ToolContext<()> {
182        ToolContext::new(())
183    }
184
185    // ===================
186    // Unit Tests
187    // ===================
188
189    #[tokio::test]
190    async fn test_glob_simple_pattern() -> anyhow::Result<()> {
191        let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
192        fs.write_file("src/main.rs", "fn main() {}").await?;
193        fs.write_file("src/lib.rs", "pub mod foo;").await?;
194        fs.write_file("README.md", "# README").await?;
195
196        let tool = create_test_tool(fs, AgentCapabilities::full_access());
197        let result = tool
198            .execute(&tool_ctx(), json!({"pattern": "src/*.rs"}))
199            .await?;
200
201        assert!(result.success);
202        assert!(result.output.contains("Found 2 files"));
203        assert!(result.output.contains("main.rs"));
204        assert!(result.output.contains("lib.rs"));
205        Ok(())
206    }
207
208    #[tokio::test]
209    async fn test_glob_recursive_pattern() -> anyhow::Result<()> {
210        let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
211        fs.write_file("src/main.rs", "fn main() {}").await?;
212        fs.write_file("src/lib/utils.rs", "pub fn util() {}")
213            .await?;
214        fs.write_file("tests/test.rs", "// test").await?;
215
216        let tool = create_test_tool(fs, AgentCapabilities::full_access());
217        let result = tool
218            .execute(&tool_ctx(), json!({"pattern": "**/*.rs"}))
219            .await?;
220
221        assert!(result.success);
222        assert!(result.output.contains("Found 3 files"));
223        Ok(())
224    }
225
226    #[tokio::test]
227    async fn test_glob_no_matches() -> anyhow::Result<()> {
228        let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
229        fs.write_file("src/main.rs", "fn main() {}").await?;
230
231        let tool = create_test_tool(fs, AgentCapabilities::full_access());
232        let result = tool
233            .execute(&tool_ctx(), json!({"pattern": "*.py"}))
234            .await?;
235
236        assert!(result.success);
237        assert!(result.output.contains("No files found"));
238        Ok(())
239    }
240
241    #[tokio::test]
242    async fn test_glob_with_path() -> anyhow::Result<()> {
243        let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
244        fs.write_file("src/main.rs", "fn main() {}").await?;
245        fs.write_file("tests/test.rs", "// test").await?;
246
247        let tool = create_test_tool(fs, AgentCapabilities::full_access());
248        let result = tool
249            .execute(
250                &tool_ctx(),
251                json!({"pattern": "*.rs", "path": "/workspace/src"}),
252            )
253            .await?;
254
255        assert!(result.success);
256        assert!(result.output.contains("Found 1 files"));
257        assert!(result.output.contains("main.rs"));
258        Ok(())
259    }
260
261    // ===================
262    // Integration Tests
263    // ===================
264
265    #[tokio::test]
266    async fn test_glob_permission_denied() -> anyhow::Result<()> {
267        let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
268        fs.write_file("src/main.rs", "fn main() {}").await?;
269
270        // No read permission
271        let caps = AgentCapabilities::none();
272
273        let tool = create_test_tool(fs, caps);
274        let result = tool
275            .execute(&tool_ctx(), json!({"pattern": "**/*.rs"}))
276            .await?;
277
278        assert!(!result.success);
279        assert!(result.output.contains("Permission denied"));
280        Ok(())
281    }
282
283    #[tokio::test]
284    async fn test_glob_filters_inaccessible_files() -> anyhow::Result<()> {
285        let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
286        fs.write_file("src/main.rs", "fn main() {}").await?;
287        fs.write_file("secrets/key.rs", "// secret").await?;
288
289        // Allow src but deny secrets
290        let caps =
291            AgentCapabilities::read_only().with_denied_paths(vec!["/workspace/secrets/**".into()]);
292
293        let tool = create_test_tool(fs, caps);
294        let result = tool
295            .execute(&tool_ctx(), json!({"pattern": "**/*.rs"}))
296            .await?;
297
298        assert!(result.success);
299        assert!(result.output.contains("Found 1 files"));
300        assert!(result.output.contains("main.rs"));
301        assert!(!result.output.contains("key.rs"));
302        Ok(())
303    }
304
305    #[tokio::test]
306    async fn test_glob_allowed_paths_restriction() -> anyhow::Result<()> {
307        let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
308        fs.write_file("src/main.rs", "fn main() {}").await?;
309        fs.write_file("config/settings.toml", "key = value").await?;
310
311        // Full access with denied paths for config
312        let caps =
313            AgentCapabilities::read_only().with_denied_paths(vec!["/workspace/config/**".into()]);
314
315        let tool = create_test_tool(fs, caps);
316
317        // Searching should return src files but not config
318        let result = tool
319            .execute(&tool_ctx(), json!({"pattern": "**/*"}))
320            .await?;
321
322        assert!(result.success);
323        assert!(result.output.contains("main.rs"));
324        assert!(!result.output.contains("settings.toml"));
325        Ok(())
326    }
327
328    // ===================
329    // Edge Cases
330    // ===================
331
332    #[tokio::test]
333    async fn test_glob_empty_directory() -> anyhow::Result<()> {
334        let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
335        fs.create_dir("/workspace/empty").await?;
336
337        let tool = create_test_tool(fs, AgentCapabilities::full_access());
338        let result = tool
339            .execute(
340                &tool_ctx(),
341                json!({"pattern": "*", "path": "/workspace/empty"}),
342            )
343            .await?;
344
345        assert!(result.success);
346        assert!(result.output.contains("No files found"));
347        Ok(())
348    }
349
350    #[tokio::test]
351    async fn test_glob_many_files_truncated() -> anyhow::Result<()> {
352        let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
353
354        // Create 150 files
355        for i in 0..150 {
356            fs.write_file(&format!("files/file{i}.txt"), "content")
357                .await?;
358        }
359
360        let tool = create_test_tool(fs, AgentCapabilities::full_access());
361        let result = tool
362            .execute(&tool_ctx(), json!({"pattern": "files/*.txt"}))
363            .await?;
364
365        assert!(result.success);
366        assert!(result.output.contains("Found 150 files"));
367        assert!(result.output.contains("showing first 100"));
368        Ok(())
369    }
370
371    #[tokio::test]
372    async fn test_glob_tool_metadata() {
373        let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
374        let tool = create_test_tool(fs, AgentCapabilities::full_access());
375
376        assert_eq!(Tool::<()>::name(&tool), PrimitiveToolName::Glob);
377        assert_eq!(Tool::<()>::tier(&tool), ToolTier::Observe);
378        assert!(Tool::<()>::description(&tool).contains("glob"));
379
380        let schema = Tool::<()>::input_schema(&tool);
381        assert!(schema.get("properties").is_some());
382        assert!(schema["properties"].get("pattern").is_some());
383        assert!(schema["properties"].get("path").is_some());
384    }
385
386    #[tokio::test]
387    async fn test_glob_invalid_input() -> anyhow::Result<()> {
388        let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
389        let tool = create_test_tool(fs, AgentCapabilities::full_access());
390
391        // Missing required pattern field
392        let result = tool.execute(&tool_ctx(), json!({})).await;
393        assert!(result.is_err());
394        Ok(())
395    }
396
397    #[tokio::test]
398    async fn test_glob_absolute_pattern() -> anyhow::Result<()> {
399        let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
400        fs.write_file("src/main.rs", "fn main() {}").await?;
401        fs.write_file("src/lib.rs", "pub mod foo;").await?;
402        fs.write_file("README.md", "# README").await?;
403
404        let tool = create_test_tool(fs, AgentCapabilities::full_access());
405        // An absolute pattern must NOT be re-joined onto the root (which would
406        // produce '/workspace//workspace/src/*.rs' and match nothing).
407        let result = tool
408            .execute(&tool_ctx(), json!({"pattern": "/workspace/src/*.rs"}))
409            .await?;
410
411        assert!(result.success);
412        assert!(result.output.contains("Found 2 files"));
413        assert!(result.output.contains("main.rs"));
414        assert!(result.output.contains("lib.rs"));
415        Ok(())
416    }
417
418    #[tokio::test]
419    async fn test_glob_rejects_parent_segment() -> anyhow::Result<()> {
420        let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
421        fs.write_file("src/main.rs", "fn main() {}").await?;
422
423        let tool = create_test_tool(fs, AgentCapabilities::full_access());
424        let result = tool
425            .execute(&tool_ctx(), json!({"pattern": "../*.rs"}))
426            .await?;
427
428        assert!(!result.success);
429        assert!(result.output.contains(".."));
430        Ok(())
431    }
432
433    #[tokio::test]
434    async fn test_glob_invalid_pattern() -> anyhow::Result<()> {
435        let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
436        fs.write_file("src/main.rs", "fn main() {}").await?;
437
438        let tool = create_test_tool(fs, AgentCapabilities::full_access());
439        // An unbalanced char class is invalid; the model should get a
440        // correctable tool error, not an infrastructure failure.
441        let result = tool
442            .execute(&tool_ctx(), json!({"pattern": "[unclosed"}))
443            .await?;
444
445        assert!(!result.success);
446        assert!(result.output.contains("Invalid glob pattern"));
447        Ok(())
448    }
449
450    #[tokio::test]
451    async fn test_glob_specific_file_extension() -> anyhow::Result<()> {
452        let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
453        fs.write_file("main.rs", "fn main() {}").await?;
454        fs.write_file("main.go", "package main").await?;
455        fs.write_file("main.py", "def main(): pass").await?;
456
457        let tool = create_test_tool(fs, AgentCapabilities::full_access());
458        let result = tool
459            .execute(&tool_ctx(), json!({"pattern": "*.rs"}))
460            .await?;
461
462        assert!(result.success);
463        assert!(result.output.contains("Found 1 files"));
464        assert!(result.output.contains("main.rs"));
465        assert!(!result.output.contains("main.go"));
466        assert!(!result.output.contains("main.py"));
467        Ok(())
468    }
469}