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