construct/tools/
glob_search.rs1use super::traits::{Tool, ToolResult};
2use crate::security::SecurityPolicy;
3use async_trait::async_trait;
4use serde_json::json;
5use std::sync::Arc;
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(|| anyhow::anyhow!("Missing 'pattern' parameter"))?;
50
51 if self.security.is_rate_limited() {
53 return Ok(ToolResult {
54 success: false,
55 output: String::new(),
56 error: Some("Rate limit exceeded: too many actions in the last hour".into()),
57 });
58 }
59
60 if (pattern.starts_with('/') || pattern.starts_with('\\'))
62 && !self.security.is_under_allowed_root(pattern)
63 {
64 return Ok(ToolResult {
65 success: false,
66 output: String::new(),
67 error: Some("Absolute paths are not allowed. Use a relative glob pattern.".into()),
68 });
69 }
70
71 if pattern.contains("../") || pattern.contains("..\\") || pattern == ".." {
73 return Ok(ToolResult {
74 success: false,
75 output: String::new(),
76 error: Some("Path traversal ('..') is not allowed in glob patterns.".into()),
77 });
78 }
79
80 if !self.security.record_action() {
82 return Ok(ToolResult {
83 success: false,
84 output: String::new(),
85 error: Some("Rate limit exceeded: action budget exhausted".into()),
86 });
87 }
88
89 let full_pattern = self
92 .security
93 .resolve_tool_path(pattern)
94 .to_string_lossy()
95 .to_string();
96
97 let entries = match glob::glob(&full_pattern) {
98 Ok(paths) => paths,
99 Err(e) => {
100 return Ok(ToolResult {
101 success: false,
102 output: String::new(),
103 error: Some(format!("Invalid glob pattern: {e}")),
104 });
105 }
106 };
107
108 let workspace = &self.security.workspace_dir;
109 let workspace_canon = match std::fs::canonicalize(workspace) {
110 Ok(p) => p,
111 Err(e) => {
112 return Ok(ToolResult {
113 success: false,
114 output: String::new(),
115 error: Some(format!("Cannot resolve workspace directory: {e}")),
116 });
117 }
118 };
119
120 let mut results = Vec::new();
121 let mut truncated = false;
122
123 for entry in entries {
124 let path = match entry {
125 Ok(p) => p,
126 Err(_) => continue, };
128
129 let resolved = match std::fs::canonicalize(&path) {
131 Ok(p) => p,
132 Err(_) => continue, };
134
135 if !self.security.is_resolved_path_allowed(&resolved) {
136 continue; }
138
139 if resolved.is_dir() {
141 continue;
142 }
143
144 if let Ok(rel) = resolved.strip_prefix(&workspace_canon) {
146 results.push(rel.to_string_lossy().to_string());
147 }
148
149 if results.len() >= MAX_RESULTS {
150 truncated = true;
151 break;
152 }
153 }
154
155 results.sort();
156
157 let output = if results.is_empty() {
158 format!("No files matching pattern '{pattern}' found in workspace.")
159 } else {
160 use std::fmt::Write;
161 let mut buf = results.join("\n");
162 if truncated {
163 let _ = write!(
164 buf,
165 "\n\n[Results truncated: showing first {MAX_RESULTS} of more matches]"
166 );
167 }
168 let _ = write!(buf, "\n\nTotal: {} files", results.len());
169 buf
170 };
171
172 Ok(ToolResult {
173 success: true,
174 output,
175 error: None,
176 })
177 }
178}
179
180#[cfg(test)]
181mod tests {
182 use super::*;
183 use crate::security::{AutonomyLevel, SecurityPolicy};
184 use std::path::PathBuf;
185 use tempfile::TempDir;
186
187 fn test_security(workspace: PathBuf) -> Arc<SecurityPolicy> {
188 Arc::new(SecurityPolicy {
189 autonomy: AutonomyLevel::Supervised,
190 workspace_dir: workspace,
191 ..SecurityPolicy::default()
192 })
193 }
194
195 fn test_security_with(
196 workspace: PathBuf,
197 autonomy: AutonomyLevel,
198 max_actions_per_hour: u32,
199 ) -> Arc<SecurityPolicy> {
200 Arc::new(SecurityPolicy {
201 autonomy,
202 workspace_dir: workspace,
203 max_actions_per_hour,
204 ..SecurityPolicy::default()
205 })
206 }
207
208 #[test]
209 fn glob_search_name_and_schema() {
210 let tool = GlobSearchTool::new(test_security(std::env::temp_dir()));
211 assert_eq!(tool.name(), "glob_search");
212
213 let schema = tool.parameters_schema();
214 assert!(schema["properties"]["pattern"].is_object());
215 assert!(
216 schema["required"]
217 .as_array()
218 .unwrap()
219 .contains(&json!("pattern"))
220 );
221 }
222
223 #[tokio::test]
224 async fn glob_search_single_file() {
225 let dir = TempDir::new().unwrap();
226 std::fs::write(dir.path().join("hello.txt"), "content").unwrap();
227
228 let tool = GlobSearchTool::new(test_security(dir.path().to_path_buf()));
229 let result = tool.execute(json!({"pattern": "hello.txt"})).await.unwrap();
230
231 assert!(result.success);
232 assert!(result.output.contains("hello.txt"));
233 }
234
235 #[tokio::test]
236 async fn glob_search_multiple_files() {
237 let dir = TempDir::new().unwrap();
238 std::fs::write(dir.path().join("a.txt"), "").unwrap();
239 std::fs::write(dir.path().join("b.txt"), "").unwrap();
240 std::fs::write(dir.path().join("c.rs"), "").unwrap();
241
242 let tool = GlobSearchTool::new(test_security(dir.path().to_path_buf()));
243 let result = tool.execute(json!({"pattern": "*.txt"})).await.unwrap();
244
245 assert!(result.success);
246 assert!(result.output.contains("a.txt"));
247 assert!(result.output.contains("b.txt"));
248 assert!(!result.output.contains("c.rs"));
249 }
250
251 #[tokio::test]
252 async fn glob_search_recursive() {
253 let dir = TempDir::new().unwrap();
254 std::fs::create_dir_all(dir.path().join("sub/deep")).unwrap();
255 std::fs::write(dir.path().join("root.txt"), "").unwrap();
256 std::fs::write(dir.path().join("sub/mid.txt"), "").unwrap();
257 std::fs::write(dir.path().join("sub/deep/leaf.txt"), "").unwrap();
258
259 let tool = GlobSearchTool::new(test_security(dir.path().to_path_buf()));
260 let result = tool.execute(json!({"pattern": "**/*.txt"})).await.unwrap();
261
262 assert!(result.success);
263 assert!(result.output.contains("root.txt"));
264 assert!(result.output.contains("mid.txt"));
265 assert!(result.output.contains("leaf.txt"));
266 }
267
268 #[tokio::test]
269 async fn glob_search_no_matches() {
270 let dir = TempDir::new().unwrap();
271
272 let tool = GlobSearchTool::new(test_security(dir.path().to_path_buf()));
273 let result = tool
274 .execute(json!({"pattern": "*.nonexistent"}))
275 .await
276 .unwrap();
277
278 assert!(result.success);
279 assert!(result.output.contains("No files matching pattern"));
280 }
281
282 #[tokio::test]
283 async fn glob_search_missing_param() {
284 let tool = GlobSearchTool::new(test_security(std::env::temp_dir()));
285 let result = tool.execute(json!({})).await;
286 assert!(result.is_err());
287 }
288
289 #[tokio::test]
290 async fn glob_search_rejects_absolute_path() {
291 let tool = GlobSearchTool::new(test_security(std::env::temp_dir()));
292 let result = tool.execute(json!({"pattern": "/etc/**/*"})).await.unwrap();
293
294 assert!(!result.success);
295 assert!(result.error.as_ref().unwrap().contains("Absolute paths"));
296 }
297
298 #[tokio::test]
299 async fn glob_search_rejects_path_traversal() {
300 let tool = GlobSearchTool::new(test_security(std::env::temp_dir()));
301 let result = tool
302 .execute(json!({"pattern": "../../../etc/passwd"}))
303 .await
304 .unwrap();
305
306 assert!(!result.success);
307 assert!(result.error.as_ref().unwrap().contains("Path traversal"));
308 }
309
310 #[tokio::test]
311 async fn glob_search_rejects_dotdot_only() {
312 let tool = GlobSearchTool::new(test_security(std::env::temp_dir()));
313 let result = tool.execute(json!({"pattern": ".."})).await.unwrap();
314
315 assert!(!result.success);
316 assert!(result.error.as_ref().unwrap().contains("Path traversal"));
317 }
318
319 #[cfg(unix)]
320 #[tokio::test]
321 async fn glob_search_filters_symlink_escape() {
322 use std::os::unix::fs::symlink;
323
324 let root = TempDir::new().unwrap();
325 let workspace = root.path().join("workspace");
326 let outside = root.path().join("outside");
327
328 std::fs::create_dir_all(&workspace).unwrap();
329 std::fs::create_dir_all(&outside).unwrap();
330 std::fs::write(outside.join("secret.txt"), "leaked").unwrap();
331
332 symlink(outside.join("secret.txt"), workspace.join("escape.txt")).unwrap();
334 std::fs::write(workspace.join("legit.txt"), "ok").unwrap();
336
337 let tool = GlobSearchTool::new(test_security(workspace.clone()));
338 let result = tool.execute(json!({"pattern": "*.txt"})).await.unwrap();
339
340 assert!(result.success);
341 assert!(result.output.contains("legit.txt"));
342 assert!(!result.output.contains("escape.txt"));
343 assert!(!result.output.contains("secret.txt"));
344 }
345
346 #[tokio::test]
347 async fn glob_search_readonly_mode() {
348 let dir = TempDir::new().unwrap();
349 std::fs::write(dir.path().join("file.txt"), "").unwrap();
350
351 let tool = GlobSearchTool::new(test_security_with(
352 dir.path().to_path_buf(),
353 AutonomyLevel::ReadOnly,
354 20,
355 ));
356 let result = tool.execute(json!({"pattern": "*.txt"})).await.unwrap();
357
358 assert!(result.success);
359 assert!(result.output.contains("file.txt"));
360 }
361
362 #[tokio::test]
363 async fn glob_search_rate_limited() {
364 let dir = TempDir::new().unwrap();
365 std::fs::write(dir.path().join("file.txt"), "").unwrap();
366
367 let tool = GlobSearchTool::new(test_security_with(
368 dir.path().to_path_buf(),
369 AutonomyLevel::Supervised,
370 0,
371 ));
372 let result = tool.execute(json!({"pattern": "*.txt"})).await.unwrap();
373
374 assert!(!result.success);
375 assert!(result.error.as_ref().unwrap().contains("Rate limit"));
376 }
377
378 #[tokio::test]
379 async fn glob_search_results_sorted() {
380 let dir = TempDir::new().unwrap();
381 std::fs::write(dir.path().join("c.txt"), "").unwrap();
382 std::fs::write(dir.path().join("a.txt"), "").unwrap();
383 std::fs::write(dir.path().join("b.txt"), "").unwrap();
384
385 let tool = GlobSearchTool::new(test_security(dir.path().to_path_buf()));
386 let result = tool.execute(json!({"pattern": "*.txt"})).await.unwrap();
387
388 assert!(result.success);
389 let lines: Vec<&str> = result.output.lines().collect();
390 assert!(lines.len() >= 3);
392 assert_eq!(lines[0], "a.txt");
393 assert_eq!(lines[1], "b.txt");
394 assert_eq!(lines[2], "c.txt");
395 }
396
397 #[tokio::test]
398 async fn glob_search_excludes_directories() {
399 let dir = TempDir::new().unwrap();
400 std::fs::create_dir(dir.path().join("subdir")).unwrap();
401 std::fs::write(dir.path().join("file.txt"), "").unwrap();
402
403 let tool = GlobSearchTool::new(test_security(dir.path().to_path_buf()));
404 let result = tool.execute(json!({"pattern": "*"})).await.unwrap();
405
406 assert!(result.success);
407 assert!(result.output.contains("file.txt"));
408 assert!(!result.output.contains("subdir"));
409 }
410
411 #[tokio::test]
412 async fn glob_search_invalid_pattern() {
413 let dir = TempDir::new().unwrap();
414
415 let tool = GlobSearchTool::new(test_security(dir.path().to_path_buf()));
416 let result = tool.execute(json!({"pattern": "[invalid"})).await.unwrap();
417
418 assert!(!result.success);
419 assert!(
420 result
421 .error
422 .as_ref()
423 .unwrap()
424 .contains("Invalid glob pattern")
425 );
426 }
427}