Skip to main content

zeroclaw_tools/
glob_search.rs

1use async_trait::async_trait;
2use serde_json::json;
3use std::sync::Arc;
4use zeroclaw_api::tool::{Tool, ToolResult};
5use zeroclaw_config::policy::SecurityPolicy;
6
7const MAX_RESULTS: usize = 1000;
8
9/// Search for files by glob pattern within the workspace.
10pub struct GlobSearchTool {
11    security: Arc<SecurityPolicy>,
12}
13
14impl GlobSearchTool {
15    pub fn new(security: Arc<SecurityPolicy>) -> Self {
16        Self { security }
17    }
18}
19
20#[async_trait]
21impl Tool for GlobSearchTool {
22    fn name(&self) -> &str {
23        "glob_search"
24    }
25
26    fn description(&self) -> &str {
27        "Search for files matching a glob pattern within the workspace. \
28         Returns a sorted list of matching file paths relative to the workspace root. \
29         Examples: '**/*.rs' (all Rust files), 'src/**/mod.rs' (all mod.rs in src)."
30    }
31
32    fn parameters_schema(&self) -> serde_json::Value {
33        json!({
34            "type": "object",
35            "properties": {
36                "pattern": {
37                    "type": "string",
38                    "description": "Glob pattern to match files, e.g. '**/*.rs', 'src/**/mod.rs'"
39                }
40            },
41            "required": ["pattern"]
42        })
43    }
44
45    async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
46        let pattern = args
47            .get("pattern")
48            .and_then(|v| v.as_str())
49            .ok_or_else(|| {
50                ::zeroclaw_log::record!(
51                    WARN,
52                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
53                        .with_outcome(::zeroclaw_log::EventOutcome::Failure)
54                        .with_attrs(::serde_json::json!({"param": "pattern"})),
55                    "glob_search: missing pattern parameter"
56                );
57                anyhow::Error::msg("Missing 'pattern' parameter")
58            })?;
59
60        // Rate limiting and path-allowlist checks are applied by the
61        // RateLimitedTool + PathGuardedTool wrappers at registration time
62        // (see zeroclaw-runtime::tools::mod).
63
64        // Security: reject absolute paths unless under an explicit allowed root.
65        if (pattern.starts_with('/') || pattern.starts_with('\\'))
66            && !self.security.is_under_allowed_root(pattern)
67        {
68            return Ok(ToolResult {
69                success: false,
70                output: String::new(),
71                error: Some("Absolute paths are not allowed. Use a relative glob pattern.".into()),
72            });
73        }
74
75        // Security: reject path traversal
76        if pattern.contains("../") || pattern.contains("..\\") || pattern == ".." {
77            return Ok(ToolResult {
78                success: false,
79                output: String::new(),
80                error: Some("Path traversal ('..') is not allowed in glob patterns.".into()),
81            });
82        }
83
84        // Build full pattern: use resolve_tool_path to handle tilde expansion
85        // and absolute paths correctly.
86        let full_pattern = self
87            .security
88            .resolve_tool_path(pattern)
89            .to_string_lossy()
90            .to_string();
91
92        let entries = match glob::glob(&full_pattern) {
93            Ok(paths) => paths,
94            Err(e) => {
95                return Ok(ToolResult {
96                    success: false,
97                    output: String::new(),
98                    error: Some(format!("Invalid glob pattern: {e}")),
99                });
100            }
101        };
102
103        let workspace = &self.security.workspace_dir;
104        let workspace_canon = match std::fs::canonicalize(workspace) {
105            Ok(p) => p,
106            Err(e) => {
107                return Ok(ToolResult {
108                    success: false,
109                    output: String::new(),
110                    error: Some(format!("Cannot resolve workspace directory: {e}")),
111                });
112            }
113        };
114
115        let mut results = Vec::new();
116        let mut truncated = false;
117
118        for entry in entries {
119            let path = match entry {
120                Ok(p) => p,
121                Err(_) => continue, // skip unreadable entries
122            };
123
124            // Canonicalize to resolve symlinks, then verify still inside workspace
125            let resolved = match std::fs::canonicalize(&path) {
126                Ok(p) => p,
127                Err(_) => continue, // skip broken symlinks / unresolvable paths
128            };
129
130            if !self.security.is_resolved_path_readable(&resolved) {
131                continue;
132            }
133
134            // Only include files, not directories
135            if resolved.is_dir() {
136                continue;
137            }
138
139            // Convert to workspace-relative path
140            if let Ok(rel) = resolved.strip_prefix(&workspace_canon) {
141                results.push(rel.to_string_lossy().to_string());
142            }
143
144            if results.len() >= MAX_RESULTS {
145                truncated = true;
146                break;
147            }
148        }
149
150        results.sort();
151
152        let output = if results.is_empty() {
153            format!("No files matching pattern '{pattern}' found in workspace.")
154        } else {
155            use std::fmt::Write;
156            let mut buf = results.join("\n");
157            if truncated {
158                let _ = write!(
159                    buf,
160                    "\n\n[Results truncated: showing first {MAX_RESULTS} of more matches]"
161                );
162            }
163            let _ = write!(buf, "\n\nTotal: {} files", results.len());
164            buf
165        };
166
167        Ok(ToolResult {
168            success: true,
169            output,
170            error: None,
171        })
172    }
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178    use std::path::PathBuf;
179    use tempfile::TempDir;
180    use zeroclaw_config::autonomy::AutonomyLevel;
181    use zeroclaw_config::policy::SecurityPolicy;
182
183    fn test_security(workspace: PathBuf) -> Arc<SecurityPolicy> {
184        Arc::new(SecurityPolicy {
185            autonomy: AutonomyLevel::Supervised,
186            workspace_dir: workspace,
187            ..SecurityPolicy::default()
188        })
189    }
190
191    fn test_security_with(
192        workspace: PathBuf,
193        autonomy: AutonomyLevel,
194        max_actions_per_hour: u32,
195    ) -> Arc<SecurityPolicy> {
196        Arc::new(SecurityPolicy {
197            autonomy,
198            workspace_dir: workspace,
199            max_actions_per_hour,
200            ..SecurityPolicy::default()
201        })
202    }
203
204    #[test]
205    fn glob_search_name_and_schema() {
206        let tool = GlobSearchTool::new(test_security(std::env::temp_dir()));
207        assert_eq!(tool.name(), "glob_search");
208
209        let schema = tool.parameters_schema();
210        assert!(schema["properties"]["pattern"].is_object());
211        assert!(
212            schema["required"]
213                .as_array()
214                .unwrap()
215                .contains(&json!("pattern"))
216        );
217    }
218
219    #[tokio::test]
220    async fn glob_search_single_file() {
221        let dir = TempDir::new().unwrap();
222        std::fs::write(dir.path().join("hello.txt"), "content").unwrap();
223
224        let tool = GlobSearchTool::new(test_security(dir.path().to_path_buf()));
225        let result = tool.execute(json!({"pattern": "hello.txt"})).await.unwrap();
226
227        assert!(result.success);
228        assert!(result.output.contains("hello.txt"));
229    }
230
231    #[tokio::test]
232    async fn glob_search_multiple_files() {
233        let dir = TempDir::new().unwrap();
234        std::fs::write(dir.path().join("a.txt"), "").unwrap();
235        std::fs::write(dir.path().join("b.txt"), "").unwrap();
236        std::fs::write(dir.path().join("c.rs"), "").unwrap();
237
238        let tool = GlobSearchTool::new(test_security(dir.path().to_path_buf()));
239        let result = tool.execute(json!({"pattern": "*.txt"})).await.unwrap();
240
241        assert!(result.success);
242        assert!(result.output.contains("a.txt"));
243        assert!(result.output.contains("b.txt"));
244        assert!(!result.output.contains("c.rs"));
245    }
246
247    #[tokio::test]
248    async fn glob_search_recursive() {
249        let dir = TempDir::new().unwrap();
250        std::fs::create_dir_all(dir.path().join("sub/deep")).unwrap();
251        std::fs::write(dir.path().join("root.txt"), "").unwrap();
252        std::fs::write(dir.path().join("sub/mid.txt"), "").unwrap();
253        std::fs::write(dir.path().join("sub/deep/leaf.txt"), "").unwrap();
254
255        let tool = GlobSearchTool::new(test_security(dir.path().to_path_buf()));
256        let result = tool.execute(json!({"pattern": "**/*.txt"})).await.unwrap();
257
258        assert!(result.success);
259        assert!(result.output.contains("root.txt"));
260        assert!(result.output.contains("mid.txt"));
261        assert!(result.output.contains("leaf.txt"));
262    }
263
264    #[tokio::test]
265    async fn glob_search_no_matches() {
266        let dir = TempDir::new().unwrap();
267
268        let tool = GlobSearchTool::new(test_security(dir.path().to_path_buf()));
269        let result = tool
270            .execute(json!({"pattern": "*.nonexistent"}))
271            .await
272            .unwrap();
273
274        assert!(result.success);
275        assert!(result.output.contains("No files matching pattern"));
276    }
277
278    #[tokio::test]
279    async fn glob_search_missing_param() {
280        let tool = GlobSearchTool::new(test_security(std::env::temp_dir()));
281        let result = tool.execute(json!({})).await;
282        assert!(result.is_err());
283    }
284
285    #[tokio::test]
286    async fn glob_search_rejects_absolute_path() {
287        let tool = GlobSearchTool::new(test_security(std::env::temp_dir()));
288        let result = tool.execute(json!({"pattern": "/etc/**/*"})).await.unwrap();
289
290        assert!(!result.success);
291        assert!(result.error.as_ref().unwrap().contains("Absolute paths"));
292    }
293
294    #[tokio::test]
295    async fn glob_search_rejects_path_traversal() {
296        let tool = GlobSearchTool::new(test_security(std::env::temp_dir()));
297        let result = tool
298            .execute(json!({"pattern": "../../../etc/passwd"}))
299            .await
300            .unwrap();
301
302        assert!(!result.success);
303        assert!(result.error.as_ref().unwrap().contains("Path traversal"));
304    }
305
306    #[tokio::test]
307    async fn glob_search_rejects_dotdot_only() {
308        let tool = GlobSearchTool::new(test_security(std::env::temp_dir()));
309        let result = tool.execute(json!({"pattern": ".."})).await.unwrap();
310
311        assert!(!result.success);
312        assert!(result.error.as_ref().unwrap().contains("Path traversal"));
313    }
314
315    #[cfg(unix)]
316    #[tokio::test]
317    async fn glob_search_filters_symlink_escape() {
318        use std::os::unix::fs::symlink;
319
320        let root = TempDir::new().unwrap();
321        let workspace = root.path().join("workspace");
322        let outside = root.path().join("outside");
323
324        std::fs::create_dir_all(&workspace).unwrap();
325        std::fs::create_dir_all(&outside).unwrap();
326        std::fs::write(outside.join("secret.txt"), "leaked").unwrap();
327
328        // Symlink inside workspace pointing outside
329        symlink(outside.join("secret.txt"), workspace.join("escape.txt")).unwrap();
330        // Also add a legitimate file
331        std::fs::write(workspace.join("legit.txt"), "ok").unwrap();
332
333        let tool = GlobSearchTool::new(test_security(workspace.clone()));
334        let result = tool.execute(json!({"pattern": "*.txt"})).await.unwrap();
335
336        assert!(result.success);
337        assert!(result.output.contains("legit.txt"));
338        assert!(!result.output.contains("escape.txt"));
339        assert!(!result.output.contains("secret.txt"));
340    }
341
342    #[tokio::test]
343    async fn glob_search_readonly_mode() {
344        let dir = TempDir::new().unwrap();
345        std::fs::write(dir.path().join("file.txt"), "").unwrap();
346
347        let tool = GlobSearchTool::new(test_security_with(
348            dir.path().to_path_buf(),
349            AutonomyLevel::ReadOnly,
350            20,
351        ));
352        let result = tool.execute(json!({"pattern": "*.txt"})).await.unwrap();
353
354        assert!(result.success);
355        assert!(result.output.contains("file.txt"));
356    }
357
358    // Rate-limit behavior is covered by RateLimitedTool's own tests in
359    // zeroclaw-tools::wrappers; this tool delegates the concern to the wrapper
360    // at registration time.
361
362    #[tokio::test]
363    async fn glob_search_results_sorted() {
364        let dir = TempDir::new().unwrap();
365        std::fs::write(dir.path().join("c.txt"), "").unwrap();
366        std::fs::write(dir.path().join("a.txt"), "").unwrap();
367        std::fs::write(dir.path().join("b.txt"), "").unwrap();
368
369        let tool = GlobSearchTool::new(test_security(dir.path().to_path_buf()));
370        let result = tool.execute(json!({"pattern": "*.txt"})).await.unwrap();
371
372        assert!(result.success);
373        let lines: Vec<&str> = result.output.lines().collect();
374        // First 3 lines should be the sorted file names
375        assert!(lines.len() >= 3);
376        assert_eq!(lines[0], "a.txt");
377        assert_eq!(lines[1], "b.txt");
378        assert_eq!(lines[2], "c.txt");
379    }
380
381    #[tokio::test]
382    async fn glob_search_excludes_directories() {
383        let dir = TempDir::new().unwrap();
384        std::fs::create_dir(dir.path().join("subdir")).unwrap();
385        std::fs::write(dir.path().join("file.txt"), "").unwrap();
386
387        let tool = GlobSearchTool::new(test_security(dir.path().to_path_buf()));
388        let result = tool.execute(json!({"pattern": "*"})).await.unwrap();
389
390        assert!(result.success);
391        assert!(result.output.contains("file.txt"));
392        assert!(!result.output.contains("subdir"));
393    }
394
395    #[tokio::test]
396    async fn glob_search_invalid_pattern() {
397        let dir = TempDir::new().unwrap();
398
399        let tool = GlobSearchTool::new(test_security(dir.path().to_path_buf()));
400        let result = tool.execute(json!({"pattern": "[invalid"})).await.unwrap();
401
402        assert!(!result.success);
403        assert!(
404            result
405                .error
406                .as_ref()
407                .unwrap()
408                .contains("Invalid glob pattern")
409        );
410    }
411
412    #[tokio::test]
413    async fn glob_search_filters_symlink_into_write_only_root() {
414        let workspace = TempDir::new().unwrap();
415        let sibling = TempDir::new().unwrap();
416        std::fs::write(sibling.path().join("secret.txt"), "secret").unwrap();
417
418        let symlink_path = workspace.path().join("siblings");
419        #[cfg(unix)]
420        std::os::unix::fs::symlink(sibling.path(), &symlink_path).unwrap();
421        #[cfg(not(unix))]
422        return;
423
424        let security = Arc::new(SecurityPolicy {
425            autonomy: AutonomyLevel::Supervised,
426            workspace_dir: workspace.path().to_path_buf(),
427            allowed_roots_write_only: vec![sibling.path().to_path_buf()],
428            workspace_only: false,
429            ..SecurityPolicy::default()
430        });
431        let tool = GlobSearchTool::new(security);
432
433        let result = tool
434            .execute(json!({"pattern": "siblings/*"}))
435            .await
436            .unwrap();
437
438        assert!(result.success);
439        assert!(
440            !result.output.contains("secret.txt"),
441            "write-only root must not surface through glob_search even via a workspace symlink; got: {}",
442            result.output
443        );
444    }
445}