Skip to main content

zeph_tools/
file.rs

1use std::path::{Path, PathBuf};
2
3use schemars::JsonSchema;
4use serde::Deserialize;
5
6use crate::executor::{
7    DiffData, ToolCall, ToolError, ToolExecutor, ToolOutput, deserialize_params,
8};
9use crate::registry::{InvocationHint, ToolDef};
10
11#[derive(Deserialize, JsonSchema)]
12pub(crate) struct ReadParams {
13    /// File path
14    path: String,
15    /// Line offset
16    offset: Option<u32>,
17    /// Max lines
18    limit: Option<u32>,
19}
20
21#[derive(Deserialize, JsonSchema)]
22struct WriteParams {
23    /// File path
24    path: String,
25    /// Content to write
26    content: String,
27}
28
29#[derive(Deserialize, JsonSchema)]
30struct EditParams {
31    /// File path
32    path: String,
33    /// Text to find
34    old_string: String,
35    /// Replacement text
36    new_string: String,
37}
38
39#[derive(Deserialize, JsonSchema)]
40struct GlobParams {
41    /// Glob pattern
42    pattern: String,
43}
44
45#[derive(Deserialize, JsonSchema)]
46struct GrepParams {
47    /// Regex pattern
48    pattern: String,
49    /// Search path
50    path: Option<String>,
51    /// Case sensitive
52    case_sensitive: Option<bool>,
53}
54
55/// File operations executor sandboxed to allowed paths.
56#[derive(Debug)]
57pub struct FileExecutor {
58    allowed_paths: Vec<PathBuf>,
59}
60
61impl FileExecutor {
62    #[must_use]
63    pub fn new(allowed_paths: Vec<PathBuf>) -> Self {
64        let paths = if allowed_paths.is_empty() {
65            vec![std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))]
66        } else {
67            allowed_paths
68        };
69        Self {
70            allowed_paths: paths
71                .into_iter()
72                .map(|p| p.canonicalize().unwrap_or(p))
73                .collect(),
74        }
75    }
76
77    fn validate_path(&self, path: &Path) -> Result<PathBuf, ToolError> {
78        let resolved = if path.is_absolute() {
79            path.to_path_buf()
80        } else {
81            std::env::current_dir()
82                .unwrap_or_else(|_| PathBuf::from("."))
83                .join(path)
84        };
85        let canonical = resolve_via_ancestors(&resolved);
86        if !self.allowed_paths.iter().any(|a| canonical.starts_with(a)) {
87            return Err(ToolError::SandboxViolation {
88                path: canonical.display().to_string(),
89            });
90        }
91        Ok(canonical)
92    }
93
94    /// Execute a tool call by `tool_id` and params.
95    ///
96    /// # Errors
97    ///
98    /// Returns `ToolError` on sandbox violations or I/O failures.
99    pub fn execute_file_tool(
100        &self,
101        tool_id: &str,
102        params: &serde_json::Map<String, serde_json::Value>,
103    ) -> Result<Option<ToolOutput>, ToolError> {
104        match tool_id {
105            "read" => {
106                let p: ReadParams = deserialize_params(params)?;
107                self.handle_read(&p)
108            }
109            "write" => {
110                let p: WriteParams = deserialize_params(params)?;
111                self.handle_write(&p)
112            }
113            "edit" => {
114                let p: EditParams = deserialize_params(params)?;
115                self.handle_edit(&p)
116            }
117            "glob" => {
118                let p: GlobParams = deserialize_params(params)?;
119                self.handle_glob(&p)
120            }
121            "grep" => {
122                let p: GrepParams = deserialize_params(params)?;
123                self.handle_grep(&p)
124            }
125            _ => Ok(None),
126        }
127    }
128
129    fn handle_read(&self, params: &ReadParams) -> Result<Option<ToolOutput>, ToolError> {
130        let path = self.validate_path(Path::new(&params.path))?;
131        let content = std::fs::read_to_string(&path)?;
132
133        let offset = params.offset.unwrap_or(0) as usize;
134        let limit = params.limit.map_or(usize::MAX, |l| l as usize);
135
136        let selected: Vec<String> = content
137            .lines()
138            .skip(offset)
139            .take(limit)
140            .enumerate()
141            .map(|(i, line)| format!("{:>4}\t{line}", offset + i + 1))
142            .collect();
143
144        Ok(Some(ToolOutput {
145            tool_name: "read".to_owned(),
146            summary: selected.join("\n"),
147            blocks_executed: 1,
148            filter_stats: None,
149            diff: None,
150            streamed: false,
151        }))
152    }
153
154    fn handle_write(&self, params: &WriteParams) -> Result<Option<ToolOutput>, ToolError> {
155        let path = self.validate_path(Path::new(&params.path))?;
156        let old_content = std::fs::read_to_string(&path).unwrap_or_default();
157
158        if let Some(parent) = path.parent() {
159            std::fs::create_dir_all(parent)?;
160        }
161        std::fs::write(&path, &params.content)?;
162
163        Ok(Some(ToolOutput {
164            tool_name: "write".to_owned(),
165            summary: format!("Wrote {} bytes to {}", params.content.len(), params.path),
166            blocks_executed: 1,
167            filter_stats: None,
168            diff: Some(DiffData {
169                file_path: params.path.clone(),
170                old_content,
171                new_content: params.content.clone(),
172            }),
173            streamed: false,
174        }))
175    }
176
177    fn handle_edit(&self, params: &EditParams) -> Result<Option<ToolOutput>, ToolError> {
178        let path = self.validate_path(Path::new(&params.path))?;
179        let content = std::fs::read_to_string(&path)?;
180
181        if !content.contains(&params.old_string) {
182            return Err(ToolError::Execution(std::io::Error::new(
183                std::io::ErrorKind::NotFound,
184                format!("old_string not found in {}", params.path),
185            )));
186        }
187
188        let new_content = content.replacen(&params.old_string, &params.new_string, 1);
189        std::fs::write(&path, &new_content)?;
190
191        Ok(Some(ToolOutput {
192            tool_name: "edit".to_owned(),
193            summary: format!("Edited {}", params.path),
194            blocks_executed: 1,
195            filter_stats: None,
196            diff: Some(DiffData {
197                file_path: params.path.clone(),
198                old_content: content,
199                new_content,
200            }),
201            streamed: false,
202        }))
203    }
204
205    fn handle_glob(&self, params: &GlobParams) -> Result<Option<ToolOutput>, ToolError> {
206        let matches: Vec<String> = glob::glob(&params.pattern)
207            .map_err(|e| {
208                ToolError::Execution(std::io::Error::new(
209                    std::io::ErrorKind::InvalidInput,
210                    e.to_string(),
211                ))
212            })?
213            .filter_map(Result::ok)
214            .filter(|p| {
215                let canonical = p.canonicalize().unwrap_or_else(|_| p.clone());
216                self.allowed_paths.iter().any(|a| canonical.starts_with(a))
217            })
218            .map(|p| p.display().to_string())
219            .collect();
220
221        Ok(Some(ToolOutput {
222            tool_name: "glob".to_owned(),
223            summary: if matches.is_empty() {
224                format!("No files matching: {}", params.pattern)
225            } else {
226                matches.join("\n")
227            },
228            blocks_executed: 1,
229            filter_stats: None,
230            diff: None,
231            streamed: false,
232        }))
233    }
234
235    fn handle_grep(&self, params: &GrepParams) -> Result<Option<ToolOutput>, ToolError> {
236        let search_path = params.path.as_deref().unwrap_or(".");
237        let case_sensitive = params.case_sensitive.unwrap_or(true);
238        let path = self.validate_path(Path::new(search_path))?;
239
240        let regex = if case_sensitive {
241            regex::Regex::new(&params.pattern)
242        } else {
243            regex::RegexBuilder::new(&params.pattern)
244                .case_insensitive(true)
245                .build()
246        }
247        .map_err(|e| {
248            ToolError::Execution(std::io::Error::new(
249                std::io::ErrorKind::InvalidInput,
250                e.to_string(),
251            ))
252        })?;
253
254        let mut results = Vec::new();
255        grep_recursive(&path, &regex, &mut results, 100)?;
256
257        Ok(Some(ToolOutput {
258            tool_name: "grep".to_owned(),
259            summary: if results.is_empty() {
260                format!("No matches for: {}", params.pattern)
261            } else {
262                results.join("\n")
263            },
264            blocks_executed: 1,
265            filter_stats: None,
266            diff: None,
267            streamed: false,
268        }))
269    }
270}
271
272impl ToolExecutor for FileExecutor {
273    async fn execute(&self, _response: &str) -> Result<Option<ToolOutput>, ToolError> {
274        Ok(None)
275    }
276
277    async fn execute_tool_call(&self, call: &ToolCall) -> Result<Option<ToolOutput>, ToolError> {
278        self.execute_file_tool(&call.tool_id, &call.params)
279    }
280
281    fn tool_definitions(&self) -> Vec<ToolDef> {
282        vec![
283            ToolDef {
284                id: "read",
285                description: "Read file contents with optional offset/limit",
286                schema: schemars::schema_for!(ReadParams),
287                invocation: InvocationHint::ToolCall,
288            },
289            ToolDef {
290                id: "write",
291                description: "Write content to a file",
292                schema: schemars::schema_for!(WriteParams),
293                invocation: InvocationHint::ToolCall,
294            },
295            ToolDef {
296                id: "edit",
297                description: "Replace a string in a file",
298                schema: schemars::schema_for!(EditParams),
299                invocation: InvocationHint::ToolCall,
300            },
301            ToolDef {
302                id: "glob",
303                description: "Find files matching a glob pattern",
304                schema: schemars::schema_for!(GlobParams),
305                invocation: InvocationHint::ToolCall,
306            },
307            ToolDef {
308                id: "grep",
309                description: "Search file contents with regex",
310                schema: schemars::schema_for!(GrepParams),
311                invocation: InvocationHint::ToolCall,
312            },
313        ]
314    }
315}
316
317/// Canonicalize a path by walking up to the nearest existing ancestor.
318fn resolve_via_ancestors(path: &Path) -> PathBuf {
319    let mut existing = path;
320    let mut suffix = PathBuf::new();
321    while !existing.exists() {
322        if let Some(parent) = existing.parent() {
323            if let Some(name) = existing.file_name() {
324                if suffix.as_os_str().is_empty() {
325                    suffix = PathBuf::from(name);
326                } else {
327                    suffix = PathBuf::from(name).join(&suffix);
328                }
329            }
330            existing = parent;
331        } else {
332            break;
333        }
334    }
335    let base = existing.canonicalize().unwrap_or(existing.to_path_buf());
336    if suffix.as_os_str().is_empty() {
337        base
338    } else {
339        base.join(&suffix)
340    }
341}
342
343const IGNORED_DIRS: &[&str] = &[".git", "target", "node_modules", ".hg"];
344
345fn grep_recursive(
346    path: &Path,
347    regex: &regex::Regex,
348    results: &mut Vec<String>,
349    limit: usize,
350) -> Result<(), ToolError> {
351    if results.len() >= limit {
352        return Ok(());
353    }
354    if path.is_file() {
355        if let Ok(content) = std::fs::read_to_string(path) {
356            for (i, line) in content.lines().enumerate() {
357                if regex.is_match(line) {
358                    results.push(format!("{}:{}: {line}", path.display(), i + 1));
359                    if results.len() >= limit {
360                        return Ok(());
361                    }
362                }
363            }
364        }
365    } else if path.is_dir() {
366        let entries = std::fs::read_dir(path)?;
367        for entry in entries.flatten() {
368            let p = entry.path();
369            let name = p.file_name().and_then(|n| n.to_str());
370            if name.is_some_and(|n| n.starts_with('.') || IGNORED_DIRS.contains(&n)) {
371                continue;
372            }
373            grep_recursive(&p, regex, results, limit)?;
374        }
375    }
376    Ok(())
377}
378
379#[cfg(test)]
380mod tests {
381    use super::*;
382    use std::fs;
383
384    fn temp_dir() -> tempfile::TempDir {
385        tempfile::tempdir().unwrap()
386    }
387
388    fn make_params(
389        pairs: &[(&str, serde_json::Value)],
390    ) -> serde_json::Map<String, serde_json::Value> {
391        pairs
392            .iter()
393            .map(|(k, v)| ((*k).to_owned(), v.clone()))
394            .collect()
395    }
396
397    #[test]
398    fn read_file() {
399        let dir = temp_dir();
400        let file = dir.path().join("test.txt");
401        fs::write(&file, "line1\nline2\nline3\n").unwrap();
402
403        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
404        let params = make_params(&[("path", serde_json::json!(file.to_str().unwrap()))]);
405        let result = exec.execute_file_tool("read", &params).unwrap().unwrap();
406        assert_eq!(result.tool_name, "read");
407        assert!(result.summary.contains("line1"));
408        assert!(result.summary.contains("line3"));
409    }
410
411    #[test]
412    fn read_with_offset_and_limit() {
413        let dir = temp_dir();
414        let file = dir.path().join("test.txt");
415        fs::write(&file, "a\nb\nc\nd\ne\n").unwrap();
416
417        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
418        let params = make_params(&[
419            ("path", serde_json::json!(file.to_str().unwrap())),
420            ("offset", serde_json::json!(1)),
421            ("limit", serde_json::json!(2)),
422        ]);
423        let result = exec.execute_file_tool("read", &params).unwrap().unwrap();
424        assert!(result.summary.contains("b"));
425        assert!(result.summary.contains("c"));
426        assert!(!result.summary.contains("a"));
427        assert!(!result.summary.contains("d"));
428    }
429
430    #[test]
431    fn write_file() {
432        let dir = temp_dir();
433        let file = dir.path().join("out.txt");
434
435        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
436        let params = make_params(&[
437            ("path", serde_json::json!(file.to_str().unwrap())),
438            ("content", serde_json::json!("hello world")),
439        ]);
440        let result = exec.execute_file_tool("write", &params).unwrap().unwrap();
441        assert!(result.summary.contains("11 bytes"));
442        assert_eq!(fs::read_to_string(&file).unwrap(), "hello world");
443    }
444
445    #[test]
446    fn edit_file() {
447        let dir = temp_dir();
448        let file = dir.path().join("edit.txt");
449        fs::write(&file, "foo bar baz").unwrap();
450
451        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
452        let params = make_params(&[
453            ("path", serde_json::json!(file.to_str().unwrap())),
454            ("old_string", serde_json::json!("bar")),
455            ("new_string", serde_json::json!("qux")),
456        ]);
457        let result = exec.execute_file_tool("edit", &params).unwrap().unwrap();
458        assert!(result.summary.contains("Edited"));
459        assert_eq!(fs::read_to_string(&file).unwrap(), "foo qux baz");
460    }
461
462    #[test]
463    fn edit_not_found() {
464        let dir = temp_dir();
465        let file = dir.path().join("edit.txt");
466        fs::write(&file, "foo bar").unwrap();
467
468        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
469        let params = make_params(&[
470            ("path", serde_json::json!(file.to_str().unwrap())),
471            ("old_string", serde_json::json!("nonexistent")),
472            ("new_string", serde_json::json!("x")),
473        ]);
474        let result = exec.execute_file_tool("edit", &params);
475        assert!(result.is_err());
476    }
477
478    #[test]
479    fn sandbox_violation() {
480        let dir = temp_dir();
481        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
482        let params = make_params(&[("path", serde_json::json!("/etc/passwd"))]);
483        let result = exec.execute_file_tool("read", &params);
484        assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
485    }
486
487    #[test]
488    fn unknown_tool_returns_none() {
489        let exec = FileExecutor::new(vec![]);
490        let params = serde_json::Map::new();
491        let result = exec.execute_file_tool("unknown", &params).unwrap();
492        assert!(result.is_none());
493    }
494
495    #[test]
496    fn glob_finds_files() {
497        let dir = temp_dir();
498        fs::write(dir.path().join("a.rs"), "").unwrap();
499        fs::write(dir.path().join("b.rs"), "").unwrap();
500
501        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
502        let pattern = format!("{}/*.rs", dir.path().display());
503        let params = make_params(&[("pattern", serde_json::json!(pattern))]);
504        let result = exec.execute_file_tool("glob", &params).unwrap().unwrap();
505        assert!(result.summary.contains("a.rs"));
506        assert!(result.summary.contains("b.rs"));
507    }
508
509    #[test]
510    fn grep_finds_matches() {
511        let dir = temp_dir();
512        fs::write(
513            dir.path().join("test.txt"),
514            "hello world\nfoo bar\nhello again\n",
515        )
516        .unwrap();
517
518        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
519        let params = make_params(&[
520            ("pattern", serde_json::json!("hello")),
521            ("path", serde_json::json!(dir.path().to_str().unwrap())),
522        ]);
523        let result = exec.execute_file_tool("grep", &params).unwrap().unwrap();
524        assert!(result.summary.contains("hello world"));
525        assert!(result.summary.contains("hello again"));
526        assert!(!result.summary.contains("foo bar"));
527    }
528
529    #[test]
530    fn write_sandbox_bypass_nonexistent_path() {
531        let dir = temp_dir();
532        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
533        let params = make_params(&[
534            ("path", serde_json::json!("/tmp/evil/escape.txt")),
535            ("content", serde_json::json!("pwned")),
536        ]);
537        let result = exec.execute_file_tool("write", &params);
538        assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
539        assert!(!Path::new("/tmp/evil/escape.txt").exists());
540    }
541
542    #[test]
543    fn glob_filters_outside_sandbox() {
544        let sandbox = temp_dir();
545        let outside = temp_dir();
546        fs::write(outside.path().join("secret.rs"), "secret").unwrap();
547
548        let exec = FileExecutor::new(vec![sandbox.path().to_path_buf()]);
549        let pattern = format!("{}/*.rs", outside.path().display());
550        let params = make_params(&[("pattern", serde_json::json!(pattern))]);
551        let result = exec.execute_file_tool("glob", &params).unwrap().unwrap();
552        assert!(!result.summary.contains("secret.rs"));
553    }
554
555    #[tokio::test]
556    async fn tool_executor_execute_tool_call_delegates() {
557        let dir = temp_dir();
558        let file = dir.path().join("test.txt");
559        fs::write(&file, "content").unwrap();
560
561        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
562        let call = ToolCall {
563            tool_id: "read".to_owned(),
564            params: make_params(&[("path", serde_json::json!(file.to_str().unwrap()))]),
565        };
566        let result = exec.execute_tool_call(&call).await.unwrap().unwrap();
567        assert_eq!(result.tool_name, "read");
568        assert!(result.summary.contains("content"));
569    }
570
571    #[test]
572    fn tool_executor_tool_definitions_lists_all() {
573        let exec = FileExecutor::new(vec![]);
574        let defs = exec.tool_definitions();
575        let ids: Vec<&str> = defs.iter().map(|d| d.id).collect();
576        assert!(ids.contains(&"read"));
577        assert!(ids.contains(&"write"));
578        assert!(ids.contains(&"edit"));
579        assert!(ids.contains(&"glob"));
580        assert!(ids.contains(&"grep"));
581        assert_eq!(defs.len(), 5);
582    }
583
584    #[test]
585    fn grep_relative_path_validated() {
586        let sandbox = temp_dir();
587        let exec = FileExecutor::new(vec![sandbox.path().to_path_buf()]);
588        let params = make_params(&[
589            ("pattern", serde_json::json!("password")),
590            ("path", serde_json::json!("../../etc")),
591        ]);
592        let result = exec.execute_file_tool("grep", &params);
593        assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
594    }
595
596    #[test]
597    fn tool_definitions_returns_five_tools() {
598        let exec = FileExecutor::new(vec![]);
599        let defs = exec.tool_definitions();
600        assert_eq!(defs.len(), 5);
601        let ids: Vec<&str> = defs.iter().map(|d| d.id).collect();
602        assert_eq!(ids, vec!["read", "write", "edit", "glob", "grep"]);
603    }
604
605    #[test]
606    fn tool_definitions_all_use_tool_call() {
607        let exec = FileExecutor::new(vec![]);
608        for def in exec.tool_definitions() {
609            assert_eq!(def.invocation, InvocationHint::ToolCall);
610        }
611    }
612
613    #[test]
614    fn tool_definitions_read_schema_has_params() {
615        let exec = FileExecutor::new(vec![]);
616        let defs = exec.tool_definitions();
617        let read = defs.iter().find(|d| d.id == "read").unwrap();
618        let obj = read.schema.as_object().unwrap();
619        let props = obj["properties"].as_object().unwrap();
620        assert!(props.contains_key("path"));
621        assert!(props.contains_key("offset"));
622        assert!(props.contains_key("limit"));
623    }
624
625    #[test]
626    fn missing_required_path_returns_invalid_params() {
627        let dir = temp_dir();
628        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
629        let params = serde_json::Map::new();
630        let result = exec.execute_file_tool("read", &params);
631        assert!(matches!(result, Err(ToolError::InvalidParams { .. })));
632    }
633}