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            locations: None,
156        }))
157    }
158
159    fn handle_write(&self, params: &WriteParams) -> Result<Option<ToolOutput>, ToolError> {
160        let path = self.validate_path(Path::new(&params.path))?;
161        let old_content = std::fs::read_to_string(&path).unwrap_or_default();
162
163        if let Some(parent) = path.parent() {
164            std::fs::create_dir_all(parent)?;
165        }
166        std::fs::write(&path, &params.content)?;
167
168        Ok(Some(ToolOutput {
169            tool_name: "write".to_owned(),
170            summary: format!("Wrote {} bytes to {}", params.content.len(), params.path),
171            blocks_executed: 1,
172            filter_stats: None,
173            diff: Some(DiffData {
174                file_path: params.path.clone(),
175                old_content,
176                new_content: params.content.clone(),
177            }),
178            streamed: false,
179            terminal_id: None,
180            locations: None,
181        }))
182    }
183
184    fn handle_edit(&self, params: &EditParams) -> Result<Option<ToolOutput>, ToolError> {
185        let path = self.validate_path(Path::new(&params.path))?;
186        let content = std::fs::read_to_string(&path)?;
187
188        if !content.contains(&params.old_string) {
189            return Err(ToolError::Execution(std::io::Error::new(
190                std::io::ErrorKind::NotFound,
191                format!("old_string not found in {}", params.path),
192            )));
193        }
194
195        let new_content = content.replacen(&params.old_string, &params.new_string, 1);
196        std::fs::write(&path, &new_content)?;
197
198        Ok(Some(ToolOutput {
199            tool_name: "edit".to_owned(),
200            summary: format!("Edited {}", params.path),
201            blocks_executed: 1,
202            filter_stats: None,
203            diff: Some(DiffData {
204                file_path: params.path.clone(),
205                old_content: content,
206                new_content,
207            }),
208            streamed: false,
209            terminal_id: None,
210            locations: None,
211        }))
212    }
213
214    fn handle_glob(&self, params: &GlobParams) -> Result<Option<ToolOutput>, ToolError> {
215        let matches: Vec<String> = glob::glob(&params.pattern)
216            .map_err(|e| {
217                ToolError::Execution(std::io::Error::new(
218                    std::io::ErrorKind::InvalidInput,
219                    e.to_string(),
220                ))
221            })?
222            .filter_map(Result::ok)
223            .filter(|p| {
224                let canonical = p.canonicalize().unwrap_or_else(|_| p.clone());
225                self.allowed_paths.iter().any(|a| canonical.starts_with(a))
226            })
227            .map(|p| p.display().to_string())
228            .collect();
229
230        Ok(Some(ToolOutput {
231            tool_name: "glob".to_owned(),
232            summary: if matches.is_empty() {
233                format!("No files matching: {}", params.pattern)
234            } else {
235                matches.join("\n")
236            },
237            blocks_executed: 1,
238            filter_stats: None,
239            diff: None,
240            streamed: false,
241            terminal_id: None,
242            locations: None,
243        }))
244    }
245
246    fn handle_grep(&self, params: &GrepParams) -> Result<Option<ToolOutput>, ToolError> {
247        let search_path = params.path.as_deref().unwrap_or(".");
248        let case_sensitive = params.case_sensitive.unwrap_or(true);
249        let path = self.validate_path(Path::new(search_path))?;
250
251        let regex = if case_sensitive {
252            regex::Regex::new(&params.pattern)
253        } else {
254            regex::RegexBuilder::new(&params.pattern)
255                .case_insensitive(true)
256                .build()
257        }
258        .map_err(|e| {
259            ToolError::Execution(std::io::Error::new(
260                std::io::ErrorKind::InvalidInput,
261                e.to_string(),
262            ))
263        })?;
264
265        let mut results = Vec::new();
266        grep_recursive(&path, &regex, &mut results, 100)?;
267
268        Ok(Some(ToolOutput {
269            tool_name: "grep".to_owned(),
270            summary: if results.is_empty() {
271                format!("No matches for: {}", params.pattern)
272            } else {
273                results.join("\n")
274            },
275            blocks_executed: 1,
276            filter_stats: None,
277            diff: None,
278            streamed: false,
279            terminal_id: None,
280            locations: None,
281        }))
282    }
283}
284
285impl ToolExecutor for FileExecutor {
286    async fn execute(&self, _response: &str) -> Result<Option<ToolOutput>, ToolError> {
287        Ok(None)
288    }
289
290    async fn execute_tool_call(&self, call: &ToolCall) -> Result<Option<ToolOutput>, ToolError> {
291        self.execute_file_tool(&call.tool_id, &call.params)
292    }
293
294    fn tool_definitions(&self) -> Vec<ToolDef> {
295        vec![
296            ToolDef {
297                id: "read".into(),
298                description: "Read file contents with optional offset/limit".into(),
299                schema: schemars::schema_for!(ReadParams),
300                invocation: InvocationHint::ToolCall,
301            },
302            ToolDef {
303                id: "write".into(),
304                description: "Write content to a file".into(),
305                schema: schemars::schema_for!(WriteParams),
306                invocation: InvocationHint::ToolCall,
307            },
308            ToolDef {
309                id: "edit".into(),
310                description: "Replace a string in a file".into(),
311                schema: schemars::schema_for!(EditParams),
312                invocation: InvocationHint::ToolCall,
313            },
314            ToolDef {
315                id: "glob".into(),
316                description: "Find files matching a glob pattern".into(),
317                schema: schemars::schema_for!(GlobParams),
318                invocation: InvocationHint::ToolCall,
319            },
320            ToolDef {
321                id: "grep".into(),
322                description: "Search file contents with regex".into(),
323                schema: schemars::schema_for!(GrepParams),
324                invocation: InvocationHint::ToolCall,
325            },
326        ]
327    }
328}
329
330/// Canonicalize a path by walking up to the nearest existing ancestor.
331fn resolve_via_ancestors(path: &Path) -> PathBuf {
332    let mut existing = path;
333    let mut suffix = PathBuf::new();
334    while !existing.exists() {
335        if let Some(parent) = existing.parent() {
336            if let Some(name) = existing.file_name() {
337                if suffix.as_os_str().is_empty() {
338                    suffix = PathBuf::from(name);
339                } else {
340                    suffix = PathBuf::from(name).join(&suffix);
341                }
342            }
343            existing = parent;
344        } else {
345            break;
346        }
347    }
348    let base = existing.canonicalize().unwrap_or(existing.to_path_buf());
349    if suffix.as_os_str().is_empty() {
350        base
351    } else {
352        base.join(&suffix)
353    }
354}
355
356const IGNORED_DIRS: &[&str] = &[".git", "target", "node_modules", ".hg"];
357
358fn grep_recursive(
359    path: &Path,
360    regex: &regex::Regex,
361    results: &mut Vec<String>,
362    limit: usize,
363) -> Result<(), ToolError> {
364    if results.len() >= limit {
365        return Ok(());
366    }
367    if path.is_file() {
368        if let Ok(content) = std::fs::read_to_string(path) {
369            for (i, line) in content.lines().enumerate() {
370                if regex.is_match(line) {
371                    results.push(format!("{}:{}: {line}", path.display(), i + 1));
372                    if results.len() >= limit {
373                        return Ok(());
374                    }
375                }
376            }
377        }
378    } else if path.is_dir() {
379        let entries = std::fs::read_dir(path)?;
380        for entry in entries.flatten() {
381            let p = entry.path();
382            let name = p.file_name().and_then(|n| n.to_str());
383            if name.is_some_and(|n| n.starts_with('.') || IGNORED_DIRS.contains(&n)) {
384                continue;
385            }
386            grep_recursive(&p, regex, results, limit)?;
387        }
388    }
389    Ok(())
390}
391
392#[cfg(test)]
393mod tests {
394    use super::*;
395    use std::fs;
396
397    fn temp_dir() -> tempfile::TempDir {
398        tempfile::tempdir().unwrap()
399    }
400
401    fn make_params(
402        pairs: &[(&str, serde_json::Value)],
403    ) -> serde_json::Map<String, serde_json::Value> {
404        pairs
405            .iter()
406            .map(|(k, v)| ((*k).to_owned(), v.clone()))
407            .collect()
408    }
409
410    #[test]
411    fn read_file() {
412        let dir = temp_dir();
413        let file = dir.path().join("test.txt");
414        fs::write(&file, "line1\nline2\nline3\n").unwrap();
415
416        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
417        let params = make_params(&[("path", serde_json::json!(file.to_str().unwrap()))]);
418        let result = exec.execute_file_tool("read", &params).unwrap().unwrap();
419        assert_eq!(result.tool_name, "read");
420        assert!(result.summary.contains("line1"));
421        assert!(result.summary.contains("line3"));
422    }
423
424    #[test]
425    fn read_with_offset_and_limit() {
426        let dir = temp_dir();
427        let file = dir.path().join("test.txt");
428        fs::write(&file, "a\nb\nc\nd\ne\n").unwrap();
429
430        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
431        let params = make_params(&[
432            ("path", serde_json::json!(file.to_str().unwrap())),
433            ("offset", serde_json::json!(1)),
434            ("limit", serde_json::json!(2)),
435        ]);
436        let result = exec.execute_file_tool("read", &params).unwrap().unwrap();
437        assert!(result.summary.contains("b"));
438        assert!(result.summary.contains("c"));
439        assert!(!result.summary.contains("a"));
440        assert!(!result.summary.contains("d"));
441    }
442
443    #[test]
444    fn write_file() {
445        let dir = temp_dir();
446        let file = dir.path().join("out.txt");
447
448        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
449        let params = make_params(&[
450            ("path", serde_json::json!(file.to_str().unwrap())),
451            ("content", serde_json::json!("hello world")),
452        ]);
453        let result = exec.execute_file_tool("write", &params).unwrap().unwrap();
454        assert!(result.summary.contains("11 bytes"));
455        assert_eq!(fs::read_to_string(&file).unwrap(), "hello world");
456    }
457
458    #[test]
459    fn edit_file() {
460        let dir = temp_dir();
461        let file = dir.path().join("edit.txt");
462        fs::write(&file, "foo bar baz").unwrap();
463
464        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
465        let params = make_params(&[
466            ("path", serde_json::json!(file.to_str().unwrap())),
467            ("old_string", serde_json::json!("bar")),
468            ("new_string", serde_json::json!("qux")),
469        ]);
470        let result = exec.execute_file_tool("edit", &params).unwrap().unwrap();
471        assert!(result.summary.contains("Edited"));
472        assert_eq!(fs::read_to_string(&file).unwrap(), "foo qux baz");
473    }
474
475    #[test]
476    fn edit_not_found() {
477        let dir = temp_dir();
478        let file = dir.path().join("edit.txt");
479        fs::write(&file, "foo bar").unwrap();
480
481        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
482        let params = make_params(&[
483            ("path", serde_json::json!(file.to_str().unwrap())),
484            ("old_string", serde_json::json!("nonexistent")),
485            ("new_string", serde_json::json!("x")),
486        ]);
487        let result = exec.execute_file_tool("edit", &params);
488        assert!(result.is_err());
489    }
490
491    #[test]
492    fn sandbox_violation() {
493        let dir = temp_dir();
494        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
495        let params = make_params(&[("path", serde_json::json!("/etc/passwd"))]);
496        let result = exec.execute_file_tool("read", &params);
497        assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
498    }
499
500    #[test]
501    fn unknown_tool_returns_none() {
502        let exec = FileExecutor::new(vec![]);
503        let params = serde_json::Map::new();
504        let result = exec.execute_file_tool("unknown", &params).unwrap();
505        assert!(result.is_none());
506    }
507
508    #[test]
509    fn glob_finds_files() {
510        let dir = temp_dir();
511        fs::write(dir.path().join("a.rs"), "").unwrap();
512        fs::write(dir.path().join("b.rs"), "").unwrap();
513
514        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
515        let pattern = format!("{}/*.rs", dir.path().display());
516        let params = make_params(&[("pattern", serde_json::json!(pattern))]);
517        let result = exec.execute_file_tool("glob", &params).unwrap().unwrap();
518        assert!(result.summary.contains("a.rs"));
519        assert!(result.summary.contains("b.rs"));
520    }
521
522    #[test]
523    fn grep_finds_matches() {
524        let dir = temp_dir();
525        fs::write(
526            dir.path().join("test.txt"),
527            "hello world\nfoo bar\nhello again\n",
528        )
529        .unwrap();
530
531        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
532        let params = make_params(&[
533            ("pattern", serde_json::json!("hello")),
534            ("path", serde_json::json!(dir.path().to_str().unwrap())),
535        ]);
536        let result = exec.execute_file_tool("grep", &params).unwrap().unwrap();
537        assert!(result.summary.contains("hello world"));
538        assert!(result.summary.contains("hello again"));
539        assert!(!result.summary.contains("foo bar"));
540    }
541
542    #[test]
543    fn write_sandbox_bypass_nonexistent_path() {
544        let dir = temp_dir();
545        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
546        let params = make_params(&[
547            ("path", serde_json::json!("/tmp/evil/escape.txt")),
548            ("content", serde_json::json!("pwned")),
549        ]);
550        let result = exec.execute_file_tool("write", &params);
551        assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
552        assert!(!Path::new("/tmp/evil/escape.txt").exists());
553    }
554
555    #[test]
556    fn glob_filters_outside_sandbox() {
557        let sandbox = temp_dir();
558        let outside = temp_dir();
559        fs::write(outside.path().join("secret.rs"), "secret").unwrap();
560
561        let exec = FileExecutor::new(vec![sandbox.path().to_path_buf()]);
562        let pattern = format!("{}/*.rs", outside.path().display());
563        let params = make_params(&[("pattern", serde_json::json!(pattern))]);
564        let result = exec.execute_file_tool("glob", &params).unwrap().unwrap();
565        assert!(!result.summary.contains("secret.rs"));
566    }
567
568    #[tokio::test]
569    async fn tool_executor_execute_tool_call_delegates() {
570        let dir = temp_dir();
571        let file = dir.path().join("test.txt");
572        fs::write(&file, "content").unwrap();
573
574        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
575        let call = ToolCall {
576            tool_id: "read".to_owned(),
577            params: make_params(&[("path", serde_json::json!(file.to_str().unwrap()))]),
578        };
579        let result = exec.execute_tool_call(&call).await.unwrap().unwrap();
580        assert_eq!(result.tool_name, "read");
581        assert!(result.summary.contains("content"));
582    }
583
584    #[test]
585    fn tool_executor_tool_definitions_lists_all() {
586        let exec = FileExecutor::new(vec![]);
587        let defs = exec.tool_definitions();
588        let ids: Vec<&str> = defs.iter().map(|d| d.id.as_ref()).collect();
589        assert!(ids.contains(&"read"));
590        assert!(ids.contains(&"write"));
591        assert!(ids.contains(&"edit"));
592        assert!(ids.contains(&"glob"));
593        assert!(ids.contains(&"grep"));
594        assert_eq!(defs.len(), 5);
595    }
596
597    #[test]
598    fn grep_relative_path_validated() {
599        let sandbox = temp_dir();
600        let exec = FileExecutor::new(vec![sandbox.path().to_path_buf()]);
601        let params = make_params(&[
602            ("pattern", serde_json::json!("password")),
603            ("path", serde_json::json!("../../etc")),
604        ]);
605        let result = exec.execute_file_tool("grep", &params);
606        assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
607    }
608
609    #[test]
610    fn tool_definitions_returns_five_tools() {
611        let exec = FileExecutor::new(vec![]);
612        let defs = exec.tool_definitions();
613        assert_eq!(defs.len(), 5);
614        let ids: Vec<&str> = defs.iter().map(|d| d.id.as_ref()).collect();
615        assert_eq!(ids, vec!["read", "write", "edit", "glob", "grep"]);
616    }
617
618    #[test]
619    fn tool_definitions_all_use_tool_call() {
620        let exec = FileExecutor::new(vec![]);
621        for def in exec.tool_definitions() {
622            assert_eq!(def.invocation, InvocationHint::ToolCall);
623        }
624    }
625
626    #[test]
627    fn tool_definitions_read_schema_has_params() {
628        let exec = FileExecutor::new(vec![]);
629        let defs = exec.tool_definitions();
630        let read = defs.iter().find(|d| d.id.as_ref() == "read").unwrap();
631        let obj = read.schema.as_object().unwrap();
632        let props = obj["properties"].as_object().unwrap();
633        assert!(props.contains_key("path"));
634        assert!(props.contains_key("offset"));
635        assert!(props.contains_key("limit"));
636    }
637
638    #[test]
639    fn missing_required_path_returns_invalid_params() {
640        let dir = temp_dir();
641        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
642        let params = serde_json::Map::new();
643        let result = exec.execute_file_tool("read", &params);
644        assert!(matches!(result, Err(ToolError::InvalidParams { .. })));
645    }
646}