Skip to main content

zeph_tools/
file.rs

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