Skip to main content

telos_agent/tools/builtin/
glob.rs

1//! `glob` tool — list files matching a glob pattern under the workspace.
2
3use 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
13/// Built-in glob tool. Read-only; safe to run concurrently.
14pub 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        // Cap results so a pathological `**/*` doesn't dump millions of paths into the context.
55        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            // Anchor relative patterns at cwd so the model can write concise globs.
67            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                // Defensive: `../foo` style patterns can still resolve outside cwd, and
78                // a symlink inside cwd may point outside. Follow symlinks and reject
79                // any file whose canonical location is not under cwd.
80                if canonicalize_within_cwd(&context.cwd, &path).await.is_err() {
81                    continue;
82                }
83                // Display paths relative to cwd; absolute paths are noisy and leak the host layout.
84                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        // Should not include ../outside.txt even though the glob matches it.
171        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}