Skip to main content

agent_engine/tools/
find.rs

1use super::util::path_arg_never_option;
2use super::{resolve_path_in, Tool, ToolContext};
3use crate::{Result, RuntimeError};
4use serde_json::{json, Value};
5use std::time::Duration;
6use tokio::process::Command;
7
8pub struct FindTool;
9
10#[async_trait::async_trait]
11impl Tool for FindTool {
12    fn effect(&self) -> crate::tools::catalog::ToolEffect {
13        crate::tools::catalog::ToolEffect::ReadOnly
14    }
15
16    fn origin(&self) -> crate::tools::ToolOrigin {
17        crate::tools::ToolOrigin::Builtin
18    }
19
20    fn name(&self) -> &str {
21        "find"
22    }
23
24    fn description(&self) -> &str {
25        "Find files by name using glob patterns. Searches recursively from the given path. Excludes .git directories."
26    }
27
28    fn parameters(&self) -> Value {
29        json!({
30            "type": "object",
31            "properties": {
32                "pattern": {
33                    "type": "string",
34                    "description": "Glob pattern to match file names (e.g. \"*.rs\", \"Cargo.*\")"
35                },
36                "path": {
37                    "type": "string",
38                    "description": "Directory to search in (default: current directory)"
39                },
40                "type": {
41                    "type": "string",
42                    "description": "Filter by type: \"f\" for files, \"d\" for directories, \"l\" for symlinks"
43                }
44            },
45            "required": ["pattern"]
46        })
47    }
48
49    async fn execute(&self, params: Value, ctx: ToolContext) -> Result<String> {
50        let mut cmd = build_command(&params, &ctx)?;
51        let output = tokio::time::timeout(Duration::from_secs(10), cmd.output())
52            .await
53            .map_err(|_| RuntimeError::Tool("Find timed out after 10s".to_string()))?
54            .map_err(|e| RuntimeError::Tool(format!("Failed to execute find: {}", e)))?;
55
56        let stdout = String::from_utf8_lossy(&output.stdout);
57
58        if stdout.is_empty() {
59            Ok("No files found.".to_string())
60        } else {
61            Ok(stdout.trim().to_string())
62        }
63    }
64}
65
66/// Build the `find` argv. Split out so the argv can be checked (and run in a temp
67/// dir) by tests.
68fn build_command(params: &Value, ctx: &ToolContext) -> Result<Command> {
69    let pattern = params["pattern"]
70        .as_str()
71        .ok_or_else(|| RuntimeError::Tool("Missing pattern parameter".to_string()))?;
72    let path = path_arg_never_option(resolve_path_in(
73        params["path"].as_str().unwrap_or("."),
74        ctx.capabilities.cwd.as_deref(),
75    ));
76    let file_type = params["type"].as_str();
77    if let Some(t) = file_type {
78        if !matches!(t, "f" | "d" | "l") {
79            return Err(RuntimeError::Tool(format!(
80                "Invalid type {t:?}: use \"f\", \"d\" or \"l\""
81            )));
82        }
83    }
84
85    let mut cmd = Command::new("find");
86    if let Some(env) = &ctx.capabilities.env {
87        cmd.env_clear().envs(env.iter().map(|(k, v)| (k, v)));
88    }
89    cmd.arg(&path);
90
91    cmd.args(["-not", "-path", "*/.git/*"]);
92    cmd.args(["-not", "-path", "*/node_modules/*"]);
93    cmd.args(["-not", "-path", "*/target/*"]);
94
95    if let Some(t) = file_type {
96        cmd.arg("-type").arg(t);
97    }
98
99    cmd.arg("-name").arg(pattern);
100
101    Ok(cmd)
102}
103
104#[cfg(test)]
105mod tests {
106    use super::super::test_helpers::create_tool_context;
107    use super::*;
108    use crate::tools::Tool;
109    use serde_json::json;
110
111    #[test]
112    fn test_find_tool_schema() {
113        let tool = FindTool;
114        assert_eq!(tool.name(), "find");
115        assert!(!tool.description().is_empty());
116
117        let params = tool.parameters();
118        assert_eq!(params["type"], "object");
119        assert!(params["properties"].is_object());
120        assert!(params["required"].is_array());
121    }
122
123    #[tokio::test]
124    async fn test_find_tool_execution() {
125        let temp_dir = std::env::temp_dir().join("test_find_tool_execution");
126        std::fs::create_dir_all(&temp_dir).unwrap();
127
128        let test_file = temp_dir.join("test_find_me.txt");
129        std::fs::write(&test_file, "test content").unwrap();
130
131        let tool = FindTool;
132        let ctx = create_tool_context();
133
134        let params = json!({
135            "pattern": "test_find_me*",
136            "path": temp_dir.to_string_lossy()
137        });
138
139        let result = tool.execute(params, ctx).await.unwrap();
140
141        // Should contain the filename
142        assert!(result.contains("test_find_me.txt"));
143
144        // Cleanup
145        let _ = std::fs::remove_dir_all(&temp_dir);
146    }
147
148    fn first_arg(cmd: &Command) -> String {
149        cmd.as_std()
150            .get_args()
151            .next()
152            .unwrap()
153            .to_string_lossy()
154            .into_owned()
155    }
156
157    /// 01 I3: with no session cwd, a `path` starting with `-` must never
158    /// become find's first argv (GNU find would parse it as an expression).
159    #[test]
160    fn leading_dash_path_is_never_an_expression() {
161        let ctx = create_tool_context();
162        assert!(ctx.capabilities.cwd.is_none());
163        for p in ["-delete", "-exec", "--help", "-fprint"] {
164            let cmd = build_command(&json!({"pattern": "*", "path": p}), &ctx).unwrap();
165            let first = first_arg(&cmd);
166            assert!(!first.starts_with('-'), "{p} became argv[1] {first:?}");
167            assert_eq!(first, format!("./{p}"));
168        }
169        // Ordinary paths are untouched.
170        let cmd = build_command(&json!({"pattern": "*", "path": "src"}), &ctx).unwrap();
171        assert_eq!(first_arg(&cmd), "src");
172    }
173
174    /// GNU find also ends the start points at `!` and `(` (and `)`, `,`):
175    /// `path:"!"` would negate the `.git` exclusion. They must reach find as
176    /// paths.
177    #[test]
178    fn start_point_terminators_are_never_expressions() {
179        let ctx = create_tool_context();
180        for p in ["!", "(", ")", ",", "!foo", "(x"] {
181            let cmd = build_command(&json!({"pattern": "*", "path": p}), &ctx).unwrap();
182            assert_eq!(first_arg(&cmd), format!("./{p}"), "path {p:?}");
183        }
184    }
185
186    /// End to end in a temp dir: `path:"!"` must not list `.git` contents.
187    #[tokio::test]
188    async fn path_bang_does_not_expose_git_dir() {
189        let dir = tempfile::tempdir().unwrap();
190        std::fs::create_dir_all(dir.path().join(".git")).unwrap();
191        std::fs::write(dir.path().join(".git/secret_obj"), "").unwrap();
192        let ctx = create_tool_context();
193        let mut cmd = build_command(&json!({"pattern": "*", "path": "!"}), &ctx).unwrap();
194        cmd.current_dir(dir.path());
195        let out = cmd.output().await.unwrap();
196        let stdout = String::from_utf8_lossy(&out.stdout);
197        assert!(!stdout.contains("secret_obj"), "{stdout}");
198    }
199
200    /// End to end, confined to a temp dir: `path:"-delete"` must not delete
201    /// anything. Pre-fix this ran `find -delete …` and emptied the cwd.
202    #[tokio::test]
203    async fn path_dash_delete_deletes_nothing() {
204        let dir = tempfile::tempdir().unwrap();
205        let victim = dir.path().join("victim.txt");
206        std::fs::write(&victim, "keep me").unwrap();
207        let ctx = create_tool_context();
208        let mut cmd = build_command(&json!({"pattern": "*", "path": "-delete"}), &ctx).unwrap();
209        cmd.current_dir(dir.path());
210        let _ = cmd.output().await.unwrap();
211        assert!(victim.exists(), "find path:-delete removed files");
212    }
213
214    #[test]
215    fn type_is_restricted_to_f_d_l() {
216        let ctx = create_tool_context();
217        for t in ["f", "d", "l"] {
218            assert!(build_command(&json!({"pattern": "*", "type": t}), &ctx).is_ok());
219        }
220        for t in ["-delete", "x", "fd", ""] {
221            assert!(
222                build_command(&json!({"pattern": "*", "type": t}), &ctx).is_err(),
223                "type {t:?} must be rejected"
224            );
225        }
226    }
227}