telos_agent/tools/builtin/
glob.rs1use async_trait::async_trait;
4use serde_json::{Value, json};
5
6use std::path::{Path, PathBuf};
7
8use crate::error::AgentError;
9use crate::tools::api::{Tool, ToolContext, ToolDefinition, ToolOutput};
10
11use super::{canonicalize_within_cwd, display_relative, required_string};
12
13pub struct GlobTool;
15
16#[async_trait]
17impl Tool for GlobTool {
18 fn definition(&self) -> ToolDefinition {
19 ToolDefinition {
20 name: "Glob".into(),
21 description: "List files matching a glob pattern under the current working directory."
22 .into(),
23 input_schema: json!({
24 "type": "object",
25 "properties": {
26 "pattern": { "type": "string" },
27 "max_results": { "type": "integer" }
28 },
29 "required": ["pattern"]
30 }),
31 }
32 }
33
34 fn prompt_text(&self) -> Option<&'static str> {
35 Some(
36 "Use Glob to list files matching a pattern. Patterns are relative to cwd; absolute patterns must stay under cwd.",
37 )
38 }
39
40 async fn validate(&self, arguments: &Value, _context: &ToolContext) -> Result<(), AgentError> {
41 required_string(arguments, "pattern").map(|_| ())
42 }
43
44 fn is_concurrency_safe(&self, _arguments: &Value) -> bool {
45 true
46 }
47
48 async fn invoke(
49 &self,
50 arguments: Value,
51 context: ToolContext,
52 ) -> Result<ToolOutput, AgentError> {
53 let pattern = required_string(&arguments, "pattern")?;
54 let max_results =
56 arguments.get("max_results").and_then(|value| value.as_u64()).unwrap_or(200) as usize;
57 let full_pattern = if Path::new(pattern).is_absolute() {
58 let anchor = absolute_glob_anchor(pattern);
59 canonicalize_within_cwd(&context.cwd, &anchor).await.map_err(|_| {
60 AgentError::PermissionDenied(format!(
61 "absolute glob pattern must stay under cwd: {pattern}"
62 ))
63 })?;
64 pattern.to_string()
65 } else {
66 context.cwd.join(pattern).to_string_lossy().to_string()
68 };
69 let mut matches = Vec::new();
70 for entry in
71 glob::glob(&full_pattern).map_err(|err| AgentError::Validation(err.to_string()))?
72 {
73 if matches.len() >= max_results {
74 break;
75 }
76 if let Ok(path) = entry {
77 if canonicalize_within_cwd(&context.cwd, &path).await.is_err() {
81 continue;
82 }
83 matches.push(display_relative(&context.cwd, &path));
85 }
86 }
87 Ok(ToolOutput::json(json!({ "matches": matches })))
88 }
89}
90
91fn absolute_glob_anchor(pattern: &str) -> PathBuf {
92 let mut anchor = PathBuf::new();
93 for component in Path::new(pattern).components() {
94 let text = component.as_os_str().to_string_lossy();
95 if text.contains('*') || text.contains('?') || text.contains('[') {
96 break;
97 }
98 anchor.push(component.as_os_str());
99 }
100 anchor
101}
102
103#[cfg(test)]
104mod tests {
105 use super::*;
106 use serde_json::json;
107 use std::collections::HashMap;
108
109 fn ctx(cwd: std::path::PathBuf) -> ToolContext {
110 ToolContext {
111 session_id: "test".into(),
112 turn_id: 1,
113 tool_call_id: None,
114 cwd,
115 env: HashMap::new(),
116 messages: std::sync::Arc::new(vec![]),
117 progress: None,
118 read_file_state: std::sync::Arc::new(tokio::sync::Mutex::new(HashMap::new())),
119 timeout: None,
120 max_file_read_bytes: usize::MAX,
121 }
122 }
123
124 #[tokio::test]
125 async fn rejects_absolute_pattern_outside_cwd() {
126 let dir = std::env::temp_dir().join("tiny_agent_glob_absolute_cwd_test");
127 let outside = std::env::temp_dir().join("tiny_agent_glob_absolute_outside_test");
128 let _ = std::fs::remove_dir_all(&dir);
129 let _ = std::fs::remove_dir_all(&outside);
130 std::fs::create_dir_all(&dir).unwrap();
131 std::fs::create_dir_all(&outside).unwrap();
132
133 let tool = GlobTool;
134 let pattern = outside.join("*").to_string_lossy().to_string();
135 let result = tool.invoke(json!({ "pattern": pattern }), ctx(dir.clone())).await;
136 assert!(matches!(result, Err(AgentError::PermissionDenied(_))));
137
138 let _ = std::fs::remove_dir_all(&dir);
139 let _ = std::fs::remove_dir_all(&outside);
140 }
141
142 #[tokio::test]
143 async fn accepts_absolute_pattern_under_cwd() {
144 let dir = std::env::temp_dir().join("tiny_agent_glob_absolute_test");
145 let _ = std::fs::remove_dir_all(&dir);
146 std::fs::create_dir_all(&dir).unwrap();
147 std::fs::write(dir.join("sample.txt"), "x").unwrap();
148
149 let tool = GlobTool;
150 let pattern = dir.join("*.txt").to_string_lossy().to_string();
151 let output = tool.invoke(json!({ "pattern": pattern }), ctx(dir.clone())).await.unwrap();
152 let matches = output.content["matches"].as_array().unwrap();
153 assert_eq!(matches, &vec![json!("sample.txt")]);
154
155 let _ = std::fs::remove_dir_all(&dir);
156 }
157
158 #[tokio::test]
159 async fn skips_matches_outside_cwd() {
160 let dir = std::env::temp_dir().join("tiny_agent_glob_escape_test");
161 let _ = std::fs::remove_dir_all(&dir);
162 std::fs::create_dir_all(dir.join("sub")).unwrap();
163 std::fs::write(dir.join("sub").join("file.txt"), "x").unwrap();
164 std::fs::write(dir.join("outside.txt"), "x").unwrap();
165
166 let tool = GlobTool;
167 let output =
168 tool.invoke(json!({ "pattern": "../**/*" }), ctx(dir.join("sub"))).await.unwrap();
169 let matches = output.content["matches"].as_array().unwrap();
170 assert!(matches.iter().all(|m| !m.as_str().unwrap().contains("outside")));
172
173 let _ = std::fs::remove_dir_all(&dir);
174 }
175
176 #[cfg(unix)]
177 #[tokio::test]
178 async fn rejects_symlink_escape() {
179 let dir = std::env::temp_dir().join("tiny_agent_glob_symlink_test");
180 let outside = std::env::temp_dir().join("tiny_agent_glob_symlink_outside");
181 let _ = std::fs::remove_dir_all(&dir);
182 let _ = std::fs::remove_dir_all(&outside);
183 std::fs::create_dir_all(&dir).unwrap();
184 std::fs::create_dir_all(&outside).unwrap();
185 std::fs::write(outside.join("secret.txt"), "x").unwrap();
186 std::os::unix::fs::symlink(outside.join("secret.txt"), dir.join("link.txt")).unwrap();
187
188 let tool = GlobTool;
189 let output = tool.invoke(json!({ "pattern": "**/*" }), ctx(dir.clone())).await.unwrap();
190 let matches = output.content["matches"].as_array().unwrap();
191 assert!(matches.is_empty(), "symlink escape should produce no matches");
192
193 let _ = std::fs::remove_dir_all(&dir);
194 let _ = std::fs::remove_dir_all(&outside);
195 }
196}