zeroclaw_tools/
glob_search.rs1use 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
9pub 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 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 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 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, };
123
124 let resolved = match std::fs::canonicalize(&path) {
126 Ok(p) => p,
127 Err(_) => continue, };
129
130 if !self.security.is_resolved_path_readable(&resolved) {
131 continue;
132 }
133
134 if resolved.is_dir() {
136 continue;
137 }
138
139 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(outside.join("secret.txt"), workspace.join("escape.txt")).unwrap();
330 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 #[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 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}