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 FindPathParams {
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#[derive(Deserialize, JsonSchema)]
59struct ListDirectoryParams {
60    /// Directory path
61    path: String,
62}
63
64#[derive(Deserialize, JsonSchema)]
65struct CreateDirectoryParams {
66    /// Directory path to create (including parents)
67    path: String,
68}
69
70#[derive(Deserialize, JsonSchema)]
71struct DeletePathParams {
72    /// Path to delete
73    path: String,
74    /// Delete non-empty directories recursively
75    #[serde(default)]
76    recursive: bool,
77}
78
79#[derive(Deserialize, JsonSchema)]
80struct MovePathParams {
81    /// Source path
82    source: String,
83    /// Destination path
84    destination: String,
85}
86
87#[derive(Deserialize, JsonSchema)]
88struct CopyPathParams {
89    /// Source path
90    source: String,
91    /// Destination path
92    destination: String,
93}
94
95/// File operations executor sandboxed to allowed paths.
96#[derive(Debug)]
97pub struct FileExecutor {
98    allowed_paths: Vec<PathBuf>,
99}
100
101fn expand_tilde(path: PathBuf) -> PathBuf {
102    let s = path.to_string_lossy();
103    if let Some(rest) = s
104        .strip_prefix("~/")
105        .or_else(|| if s == "~" { Some("") } else { None })
106        && let Some(home) = dirs::home_dir()
107    {
108        return home.join(rest);
109    }
110    path
111}
112
113impl FileExecutor {
114    #[must_use]
115    pub fn new(allowed_paths: Vec<PathBuf>) -> Self {
116        let paths = if allowed_paths.is_empty() {
117            vec![std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))]
118        } else {
119            allowed_paths.into_iter().map(expand_tilde).collect()
120        };
121        Self {
122            allowed_paths: paths
123                .into_iter()
124                .map(|p| p.canonicalize().unwrap_or(p))
125                .collect(),
126        }
127    }
128
129    fn validate_path(&self, path: &Path) -> Result<PathBuf, ToolError> {
130        let resolved = if path.is_absolute() {
131            path.to_path_buf()
132        } else {
133            std::env::current_dir()
134                .unwrap_or_else(|_| PathBuf::from("."))
135                .join(path)
136        };
137        let normalized = normalize_path(&resolved);
138        let canonical = resolve_via_ancestors(&normalized);
139        if !self.allowed_paths.iter().any(|a| canonical.starts_with(a)) {
140            return Err(ToolError::SandboxViolation {
141                path: canonical.display().to_string(),
142            });
143        }
144        Ok(canonical)
145    }
146
147    /// Execute a tool call by `tool_id` and params.
148    ///
149    /// # Errors
150    ///
151    /// Returns `ToolError` on sandbox violations or I/O failures.
152    pub fn execute_file_tool(
153        &self,
154        tool_id: &str,
155        params: &serde_json::Map<String, serde_json::Value>,
156    ) -> Result<Option<ToolOutput>, ToolError> {
157        match tool_id {
158            "read" => {
159                let p: ReadParams = deserialize_params(params)?;
160                self.handle_read(&p)
161            }
162            "write" => {
163                let p: WriteParams = deserialize_params(params)?;
164                self.handle_write(&p)
165            }
166            "edit" => {
167                let p: EditParams = deserialize_params(params)?;
168                self.handle_edit(&p)
169            }
170            "find_path" => {
171                let p: FindPathParams = deserialize_params(params)?;
172                self.handle_find_path(&p)
173            }
174            "grep" => {
175                let p: GrepParams = deserialize_params(params)?;
176                self.handle_grep(&p)
177            }
178            "list_directory" => {
179                let p: ListDirectoryParams = deserialize_params(params)?;
180                self.handle_list_directory(&p)
181            }
182            "create_directory" => {
183                let p: CreateDirectoryParams = deserialize_params(params)?;
184                self.handle_create_directory(&p)
185            }
186            "delete_path" => {
187                let p: DeletePathParams = deserialize_params(params)?;
188                self.handle_delete_path(&p)
189            }
190            "move_path" => {
191                let p: MovePathParams = deserialize_params(params)?;
192                self.handle_move_path(&p)
193            }
194            "copy_path" => {
195                let p: CopyPathParams = deserialize_params(params)?;
196                self.handle_copy_path(&p)
197            }
198            _ => Ok(None),
199        }
200    }
201
202    fn handle_read(&self, params: &ReadParams) -> Result<Option<ToolOutput>, ToolError> {
203        let path = self.validate_path(Path::new(&params.path))?;
204        let content = std::fs::read_to_string(&path)?;
205
206        let offset = params.offset.unwrap_or(0) as usize;
207        let limit = params.limit.map_or(usize::MAX, |l| l as usize);
208
209        let selected: Vec<String> = content
210            .lines()
211            .skip(offset)
212            .take(limit)
213            .enumerate()
214            .map(|(i, line)| format!("{:>4}\t{line}", offset + i + 1))
215            .collect();
216
217        Ok(Some(ToolOutput {
218            tool_name: "read".to_owned(),
219            summary: selected.join("\n"),
220            blocks_executed: 1,
221            filter_stats: None,
222            diff: None,
223            streamed: false,
224            terminal_id: None,
225            locations: None,
226            raw_response: None,
227        }))
228    }
229
230    fn handle_write(&self, params: &WriteParams) -> Result<Option<ToolOutput>, ToolError> {
231        let path = self.validate_path(Path::new(&params.path))?;
232        let old_content = std::fs::read_to_string(&path).unwrap_or_default();
233
234        if let Some(parent) = path.parent() {
235            std::fs::create_dir_all(parent)?;
236        }
237        std::fs::write(&path, &params.content)?;
238
239        Ok(Some(ToolOutput {
240            tool_name: "write".to_owned(),
241            summary: format!("Wrote {} bytes to {}", params.content.len(), params.path),
242            blocks_executed: 1,
243            filter_stats: None,
244            diff: Some(DiffData {
245                file_path: params.path.clone(),
246                old_content,
247                new_content: params.content.clone(),
248            }),
249            streamed: false,
250            terminal_id: None,
251            locations: None,
252            raw_response: None,
253        }))
254    }
255
256    fn handle_edit(&self, params: &EditParams) -> Result<Option<ToolOutput>, ToolError> {
257        let path = self.validate_path(Path::new(&params.path))?;
258        let content = std::fs::read_to_string(&path)?;
259
260        if !content.contains(&params.old_string) {
261            return Err(ToolError::Execution(std::io::Error::new(
262                std::io::ErrorKind::NotFound,
263                format!("old_string not found in {}", params.path),
264            )));
265        }
266
267        let new_content = content.replacen(&params.old_string, &params.new_string, 1);
268        std::fs::write(&path, &new_content)?;
269
270        Ok(Some(ToolOutput {
271            tool_name: "edit".to_owned(),
272            summary: format!("Edited {}", params.path),
273            blocks_executed: 1,
274            filter_stats: None,
275            diff: Some(DiffData {
276                file_path: params.path.clone(),
277                old_content: content,
278                new_content,
279            }),
280            streamed: false,
281            terminal_id: None,
282            locations: None,
283            raw_response: None,
284        }))
285    }
286
287    fn handle_find_path(&self, params: &FindPathParams) -> Result<Option<ToolOutput>, ToolError> {
288        let matches: Vec<String> = glob::glob(&params.pattern)
289            .map_err(|e| {
290                ToolError::Execution(std::io::Error::new(
291                    std::io::ErrorKind::InvalidInput,
292                    e.to_string(),
293                ))
294            })?
295            .filter_map(Result::ok)
296            .filter(|p| {
297                let canonical = p.canonicalize().unwrap_or_else(|_| p.clone());
298                self.allowed_paths.iter().any(|a| canonical.starts_with(a))
299            })
300            .map(|p| p.display().to_string())
301            .collect();
302
303        Ok(Some(ToolOutput {
304            tool_name: "find_path".to_owned(),
305            summary: if matches.is_empty() {
306                format!("No files matching: {}", params.pattern)
307            } else {
308                matches.join("\n")
309            },
310            blocks_executed: 1,
311            filter_stats: None,
312            diff: None,
313            streamed: false,
314            terminal_id: None,
315            locations: None,
316            raw_response: None,
317        }))
318    }
319
320    fn handle_grep(&self, params: &GrepParams) -> Result<Option<ToolOutput>, ToolError> {
321        let search_path = params.path.as_deref().unwrap_or(".");
322        let case_sensitive = params.case_sensitive.unwrap_or(true);
323        let path = self.validate_path(Path::new(search_path))?;
324
325        let regex = if case_sensitive {
326            regex::Regex::new(&params.pattern)
327        } else {
328            regex::RegexBuilder::new(&params.pattern)
329                .case_insensitive(true)
330                .build()
331        }
332        .map_err(|e| {
333            ToolError::Execution(std::io::Error::new(
334                std::io::ErrorKind::InvalidInput,
335                e.to_string(),
336            ))
337        })?;
338
339        let mut results = Vec::new();
340        grep_recursive(&path, &regex, &mut results, 100)?;
341
342        Ok(Some(ToolOutput {
343            tool_name: "grep".to_owned(),
344            summary: if results.is_empty() {
345                format!("No matches for: {}", params.pattern)
346            } else {
347                results.join("\n")
348            },
349            blocks_executed: 1,
350            filter_stats: None,
351            diff: None,
352            streamed: false,
353            terminal_id: None,
354            locations: None,
355            raw_response: None,
356        }))
357    }
358
359    fn handle_list_directory(
360        &self,
361        params: &ListDirectoryParams,
362    ) -> Result<Option<ToolOutput>, ToolError> {
363        let path = self.validate_path(Path::new(&params.path))?;
364
365        if !path.is_dir() {
366            return Err(ToolError::Execution(std::io::Error::new(
367                std::io::ErrorKind::NotADirectory,
368                format!("{} is not a directory", params.path),
369            )));
370        }
371
372        let mut dirs = Vec::new();
373        let mut files = Vec::new();
374        let mut symlinks = Vec::new();
375
376        for entry in std::fs::read_dir(&path)? {
377            let entry = entry?;
378            let name = entry.file_name().to_string_lossy().into_owned();
379            // Use symlink_metadata (lstat) to detect symlinks without following them.
380            let meta = std::fs::symlink_metadata(entry.path())?;
381            if meta.is_symlink() {
382                symlinks.push(format!("[symlink] {name}"));
383            } else if meta.is_dir() {
384                dirs.push(format!("[dir]  {name}"));
385            } else {
386                files.push(format!("[file] {name}"));
387            }
388        }
389
390        dirs.sort();
391        files.sort();
392        symlinks.sort();
393
394        let mut entries = dirs;
395        entries.extend(files);
396        entries.extend(symlinks);
397
398        Ok(Some(ToolOutput {
399            tool_name: "list_directory".to_owned(),
400            summary: if entries.is_empty() {
401                format!("Empty directory: {}", params.path)
402            } else {
403                entries.join("\n")
404            },
405            blocks_executed: 1,
406            filter_stats: None,
407            diff: None,
408            streamed: false,
409            terminal_id: None,
410            locations: None,
411            raw_response: None,
412        }))
413    }
414
415    fn handle_create_directory(
416        &self,
417        params: &CreateDirectoryParams,
418    ) -> Result<Option<ToolOutput>, ToolError> {
419        let path = self.validate_path(Path::new(&params.path))?;
420        std::fs::create_dir_all(&path)?;
421
422        Ok(Some(ToolOutput {
423            tool_name: "create_directory".to_owned(),
424            summary: format!("Created directory: {}", params.path),
425            blocks_executed: 1,
426            filter_stats: None,
427            diff: None,
428            streamed: false,
429            terminal_id: None,
430            locations: None,
431            raw_response: None,
432        }))
433    }
434
435    fn handle_delete_path(
436        &self,
437        params: &DeletePathParams,
438    ) -> Result<Option<ToolOutput>, ToolError> {
439        let path = self.validate_path(Path::new(&params.path))?;
440
441        // Refuse to delete the sandbox root itself
442        if self.allowed_paths.iter().any(|a| &path == a) {
443            return Err(ToolError::SandboxViolation {
444                path: path.display().to_string(),
445            });
446        }
447
448        if path.is_dir() {
449            if params.recursive {
450                // Accepted risk: remove_dir_all has no depth/size guard within the sandbox.
451                // Resource exhaustion is bounded by the filesystem and OS limits.
452                std::fs::remove_dir_all(&path)?;
453            } else {
454                // remove_dir only succeeds on empty dirs
455                std::fs::remove_dir(&path)?;
456            }
457        } else {
458            std::fs::remove_file(&path)?;
459        }
460
461        Ok(Some(ToolOutput {
462            tool_name: "delete_path".to_owned(),
463            summary: format!("Deleted: {}", params.path),
464            blocks_executed: 1,
465            filter_stats: None,
466            diff: None,
467            streamed: false,
468            terminal_id: None,
469            locations: None,
470            raw_response: None,
471        }))
472    }
473
474    fn handle_move_path(&self, params: &MovePathParams) -> Result<Option<ToolOutput>, ToolError> {
475        let src = self.validate_path(Path::new(&params.source))?;
476        let dst = self.validate_path(Path::new(&params.destination))?;
477        std::fs::rename(&src, &dst)?;
478
479        Ok(Some(ToolOutput {
480            tool_name: "move_path".to_owned(),
481            summary: format!("Moved: {} -> {}", params.source, params.destination),
482            blocks_executed: 1,
483            filter_stats: None,
484            diff: None,
485            streamed: false,
486            terminal_id: None,
487            locations: None,
488            raw_response: None,
489        }))
490    }
491
492    fn handle_copy_path(&self, params: &CopyPathParams) -> Result<Option<ToolOutput>, ToolError> {
493        let src = self.validate_path(Path::new(&params.source))?;
494        let dst = self.validate_path(Path::new(&params.destination))?;
495
496        if src.is_dir() {
497            copy_dir_recursive(&src, &dst)?;
498        } else {
499            if let Some(parent) = dst.parent() {
500                std::fs::create_dir_all(parent)?;
501            }
502            std::fs::copy(&src, &dst)?;
503        }
504
505        Ok(Some(ToolOutput {
506            tool_name: "copy_path".to_owned(),
507            summary: format!("Copied: {} -> {}", params.source, params.destination),
508            blocks_executed: 1,
509            filter_stats: None,
510            diff: None,
511            streamed: false,
512            terminal_id: None,
513            locations: None,
514            raw_response: None,
515        }))
516    }
517}
518
519impl ToolExecutor for FileExecutor {
520    async fn execute(&self, _response: &str) -> Result<Option<ToolOutput>, ToolError> {
521        Ok(None)
522    }
523
524    async fn execute_tool_call(&self, call: &ToolCall) -> Result<Option<ToolOutput>, ToolError> {
525        self.execute_file_tool(&call.tool_id, &call.params)
526    }
527
528    fn tool_definitions(&self) -> Vec<ToolDef> {
529        vec![
530            ToolDef {
531                id: "read".into(),
532                description: "Read file contents with line numbers.\n\nParameters: path (string, required) - absolute or relative file path; offset (integer, optional) - start line (0-based); limit (integer, optional) - max lines to return\nReturns: file content with line numbers, or error if file not found\nErrors: SandboxViolation if path outside allowed dirs; Execution if file not found or unreadable\nExample: {\"path\": \"src/main.rs\", \"offset\": 10, \"limit\": 50}".into(),
533                schema: schemars::schema_for!(ReadParams),
534                invocation: InvocationHint::ToolCall,
535            },
536            ToolDef {
537                id: "write".into(),
538                description: "Create or overwrite a file with the given content.\n\nParameters: path (string, required) - file path; content (string, required) - full file content\nReturns: confirmation message with bytes written\nErrors: SandboxViolation if path outside allowed dirs; Execution on I/O failure\nExample: {\"path\": \"output.txt\", \"content\": \"Hello, world!\"}".into(),
539                schema: schemars::schema_for!(WriteParams),
540                invocation: InvocationHint::ToolCall,
541            },
542            ToolDef {
543                id: "edit".into(),
544                description: "Find and replace a text substring in a file.\n\nParameters: path (string, required) - file path; old_string (string, required) - exact text to find; new_string (string, required) - replacement text\nReturns: confirmation with match count, or error if old_string not found\nErrors: SandboxViolation; Execution if file not found or old_string has no matches\nExample: {\"path\": \"config.toml\", \"old_string\": \"debug = true\", \"new_string\": \"debug = false\"}".into(),
545                schema: schemars::schema_for!(EditParams),
546                invocation: InvocationHint::ToolCall,
547            },
548            ToolDef {
549                id: "find_path".into(),
550                description: "Find files and directories matching a glob pattern.\n\nParameters: pattern (string, required) - glob pattern (e.g. \"**/*.rs\", \"src/*.toml\")\nReturns: newline-separated list of matching paths, or \"(no matches)\" if none found\nErrors: SandboxViolation if search root is outside allowed dirs\nExample: {\"pattern\": \"**/*.rs\"}".into(),
551                schema: schemars::schema_for!(FindPathParams),
552                invocation: InvocationHint::ToolCall,
553            },
554            ToolDef {
555                id: "grep".into(),
556                description: "Search file contents for lines matching a regex pattern.\n\nParameters: pattern (string, required) - regex pattern; path (string, optional) - directory or file to search (default: cwd); case_sensitive (boolean, optional) - default true\nReturns: matching lines with file paths and line numbers, or \"(no matches)\"\nErrors: SandboxViolation; InvalidParams if regex is invalid\nExample: {\"pattern\": \"fn main\", \"path\": \"src/\"}".into(),
557                schema: schemars::schema_for!(GrepParams),
558                invocation: InvocationHint::ToolCall,
559            },
560            ToolDef {
561                id: "list_directory".into(),
562                description: "List files and subdirectories in a directory.\n\nParameters: path (string, required) - directory path\nReturns: sorted listing with [dir]/[file] prefixes, or \"Empty directory\" if empty\nErrors: SandboxViolation; Execution if path is not a directory or does not exist\nExample: {\"path\": \"src/\"}".into(),
563                schema: schemars::schema_for!(ListDirectoryParams),
564                invocation: InvocationHint::ToolCall,
565            },
566            ToolDef {
567                id: "create_directory".into(),
568                description: "Create a directory, including any missing parent directories.\n\nParameters: path (string, required) - directory path to create\nReturns: confirmation message\nErrors: SandboxViolation; Execution on I/O failure\nExample: {\"path\": \"src/utils/helpers\"}".into(),
569                schema: schemars::schema_for!(CreateDirectoryParams),
570                invocation: InvocationHint::ToolCall,
571            },
572            ToolDef {
573                id: "delete_path".into(),
574                description: "Delete a file or directory.\n\nParameters: path (string, required) - path to delete; recursive (boolean, optional) - if true, delete non-empty directories recursively (default: false)\nReturns: confirmation message\nErrors: SandboxViolation; Execution if path not found or directory non-empty without recursive=true\nExample: {\"path\": \"tmp/old_file.txt\"}".into(),
575                schema: schemars::schema_for!(DeletePathParams),
576                invocation: InvocationHint::ToolCall,
577            },
578            ToolDef {
579                id: "move_path".into(),
580                description: "Move or rename a file or directory.\n\nParameters: source (string, required) - current path; destination (string, required) - new path\nReturns: confirmation message\nErrors: SandboxViolation if either path is outside allowed dirs; Execution if source not found\nExample: {\"source\": \"old_name.rs\", \"destination\": \"new_name.rs\"}".into(),
581                schema: schemars::schema_for!(MovePathParams),
582                invocation: InvocationHint::ToolCall,
583            },
584            ToolDef {
585                id: "copy_path".into(),
586                description: "Copy a file or directory to a new location.\n\nParameters: source (string, required) - path to copy; destination (string, required) - target path\nReturns: confirmation message\nErrors: SandboxViolation; Execution if source not found or I/O failure\nExample: {\"source\": \"template.rs\", \"destination\": \"new_module.rs\"}".into(),
587                schema: schemars::schema_for!(CopyPathParams),
588                invocation: InvocationHint::ToolCall,
589            },
590        ]
591    }
592}
593
594/// Lexically normalize a path by collapsing `.` and `..` components without
595/// any filesystem access. This prevents `..` components from bypassing the
596/// sandbox check inside `validate_path`.
597fn normalize_path(path: &Path) -> PathBuf {
598    use std::path::Component;
599    let mut stack: Vec<std::ffi::OsString> = Vec::new();
600    for component in path.components() {
601        match component {
602            Component::CurDir => {}
603            Component::ParentDir => {
604                stack.pop();
605            }
606            Component::Normal(name) => stack.push(name.to_owned()),
607            Component::RootDir => {
608                stack.clear();
609                stack.push(std::ffi::OsString::from("/"));
610            }
611            Component::Prefix(prefix) => {
612                stack.clear();
613                stack.push(prefix.as_os_str().to_owned());
614            }
615        }
616    }
617    let mut result = PathBuf::new();
618    for (i, part) in stack.iter().enumerate() {
619        if i == 0 && part == "/" {
620            result.push("/");
621        } else {
622            result.push(part);
623        }
624    }
625    result
626}
627
628/// Canonicalize a path by walking up to the nearest existing ancestor.
629///
630/// Walks up `path` until an existing ancestor is found, calls `canonicalize()` on it
631/// (which follows symlinks), then re-appends the non-existing suffix. The sandbox check
632/// in `validate_path` uses `starts_with` on the resulting canonical path, so symlinks
633/// that resolve outside `allowed_paths` are correctly rejected.
634fn resolve_via_ancestors(path: &Path) -> PathBuf {
635    let mut existing = path;
636    let mut suffix = PathBuf::new();
637    while !existing.exists() {
638        if let Some(parent) = existing.parent() {
639            if let Some(name) = existing.file_name() {
640                if suffix.as_os_str().is_empty() {
641                    suffix = PathBuf::from(name);
642                } else {
643                    suffix = PathBuf::from(name).join(&suffix);
644                }
645            }
646            existing = parent;
647        } else {
648            break;
649        }
650    }
651    let base = existing.canonicalize().unwrap_or(existing.to_path_buf());
652    if suffix.as_os_str().is_empty() {
653        base
654    } else {
655        base.join(&suffix)
656    }
657}
658
659const IGNORED_DIRS: &[&str] = &[".git", "target", "node_modules", ".hg"];
660
661fn grep_recursive(
662    path: &Path,
663    regex: &regex::Regex,
664    results: &mut Vec<String>,
665    limit: usize,
666) -> Result<(), ToolError> {
667    if results.len() >= limit {
668        return Ok(());
669    }
670    if path.is_file() {
671        if let Ok(content) = std::fs::read_to_string(path) {
672            for (i, line) in content.lines().enumerate() {
673                if regex.is_match(line) {
674                    results.push(format!("{}:{}: {line}", path.display(), i + 1));
675                    if results.len() >= limit {
676                        return Ok(());
677                    }
678                }
679            }
680        }
681    } else if path.is_dir() {
682        let entries = std::fs::read_dir(path)?;
683        for entry in entries.flatten() {
684            let p = entry.path();
685            let name = p.file_name().and_then(|n| n.to_str());
686            if name.is_some_and(|n| n.starts_with('.') || IGNORED_DIRS.contains(&n)) {
687                continue;
688            }
689            grep_recursive(&p, regex, results, limit)?;
690        }
691    }
692    Ok(())
693}
694
695fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<(), ToolError> {
696    std::fs::create_dir_all(dst)?;
697    for entry in std::fs::read_dir(src)? {
698        let entry = entry?;
699        // Use symlink_metadata (lstat) so we classify symlinks without following them.
700        // Symlinks are skipped to prevent escaping the sandbox via a symlink pointing
701        // to a path outside allowed_paths.
702        let meta = std::fs::symlink_metadata(entry.path())?;
703        let src_path = entry.path();
704        let dst_path = dst.join(entry.file_name());
705        if meta.is_dir() {
706            copy_dir_recursive(&src_path, &dst_path)?;
707        } else if meta.is_file() {
708            std::fs::copy(&src_path, &dst_path)?;
709        }
710        // Symlinks are intentionally skipped.
711    }
712    Ok(())
713}
714
715#[cfg(test)]
716mod tests {
717    use super::*;
718    use std::fs;
719
720    fn temp_dir() -> tempfile::TempDir {
721        tempfile::tempdir().unwrap()
722    }
723
724    fn make_params(
725        pairs: &[(&str, serde_json::Value)],
726    ) -> serde_json::Map<String, serde_json::Value> {
727        pairs
728            .iter()
729            .map(|(k, v)| ((*k).to_owned(), v.clone()))
730            .collect()
731    }
732
733    #[test]
734    fn read_file() {
735        let dir = temp_dir();
736        let file = dir.path().join("test.txt");
737        fs::write(&file, "line1\nline2\nline3\n").unwrap();
738
739        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
740        let params = make_params(&[("path", serde_json::json!(file.to_str().unwrap()))]);
741        let result = exec.execute_file_tool("read", &params).unwrap().unwrap();
742        assert_eq!(result.tool_name, "read");
743        assert!(result.summary.contains("line1"));
744        assert!(result.summary.contains("line3"));
745    }
746
747    #[test]
748    fn read_with_offset_and_limit() {
749        let dir = temp_dir();
750        let file = dir.path().join("test.txt");
751        fs::write(&file, "a\nb\nc\nd\ne\n").unwrap();
752
753        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
754        let params = make_params(&[
755            ("path", serde_json::json!(file.to_str().unwrap())),
756            ("offset", serde_json::json!(1)),
757            ("limit", serde_json::json!(2)),
758        ]);
759        let result = exec.execute_file_tool("read", &params).unwrap().unwrap();
760        assert!(result.summary.contains('b'));
761        assert!(result.summary.contains('c'));
762        assert!(!result.summary.contains('a'));
763        assert!(!result.summary.contains('d'));
764    }
765
766    #[test]
767    fn write_file() {
768        let dir = temp_dir();
769        let file = dir.path().join("out.txt");
770
771        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
772        let params = make_params(&[
773            ("path", serde_json::json!(file.to_str().unwrap())),
774            ("content", serde_json::json!("hello world")),
775        ]);
776        let result = exec.execute_file_tool("write", &params).unwrap().unwrap();
777        assert!(result.summary.contains("11 bytes"));
778        assert_eq!(fs::read_to_string(&file).unwrap(), "hello world");
779    }
780
781    #[test]
782    fn edit_file() {
783        let dir = temp_dir();
784        let file = dir.path().join("edit.txt");
785        fs::write(&file, "foo bar baz").unwrap();
786
787        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
788        let params = make_params(&[
789            ("path", serde_json::json!(file.to_str().unwrap())),
790            ("old_string", serde_json::json!("bar")),
791            ("new_string", serde_json::json!("qux")),
792        ]);
793        let result = exec.execute_file_tool("edit", &params).unwrap().unwrap();
794        assert!(result.summary.contains("Edited"));
795        assert_eq!(fs::read_to_string(&file).unwrap(), "foo qux baz");
796    }
797
798    #[test]
799    fn edit_not_found() {
800        let dir = temp_dir();
801        let file = dir.path().join("edit.txt");
802        fs::write(&file, "foo bar").unwrap();
803
804        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
805        let params = make_params(&[
806            ("path", serde_json::json!(file.to_str().unwrap())),
807            ("old_string", serde_json::json!("nonexistent")),
808            ("new_string", serde_json::json!("x")),
809        ]);
810        let result = exec.execute_file_tool("edit", &params);
811        assert!(result.is_err());
812    }
813
814    #[test]
815    fn sandbox_violation() {
816        let dir = temp_dir();
817        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
818        let params = make_params(&[("path", serde_json::json!("/etc/passwd"))]);
819        let result = exec.execute_file_tool("read", &params);
820        assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
821    }
822
823    #[test]
824    fn unknown_tool_returns_none() {
825        let exec = FileExecutor::new(vec![]);
826        let params = serde_json::Map::new();
827        let result = exec.execute_file_tool("unknown", &params).unwrap();
828        assert!(result.is_none());
829    }
830
831    #[test]
832    fn find_path_finds_files() {
833        let dir = temp_dir();
834        fs::write(dir.path().join("a.rs"), "").unwrap();
835        fs::write(dir.path().join("b.rs"), "").unwrap();
836
837        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
838        let pattern = format!("{}/*.rs", dir.path().display());
839        let params = make_params(&[("pattern", serde_json::json!(pattern))]);
840        let result = exec
841            .execute_file_tool("find_path", &params)
842            .unwrap()
843            .unwrap();
844        assert!(result.summary.contains("a.rs"));
845        assert!(result.summary.contains("b.rs"));
846    }
847
848    #[test]
849    fn grep_finds_matches() {
850        let dir = temp_dir();
851        fs::write(
852            dir.path().join("test.txt"),
853            "hello world\nfoo bar\nhello again\n",
854        )
855        .unwrap();
856
857        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
858        let params = make_params(&[
859            ("pattern", serde_json::json!("hello")),
860            ("path", serde_json::json!(dir.path().to_str().unwrap())),
861        ]);
862        let result = exec.execute_file_tool("grep", &params).unwrap().unwrap();
863        assert!(result.summary.contains("hello world"));
864        assert!(result.summary.contains("hello again"));
865        assert!(!result.summary.contains("foo bar"));
866    }
867
868    #[test]
869    fn write_sandbox_bypass_nonexistent_path() {
870        let dir = temp_dir();
871        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
872        let params = make_params(&[
873            ("path", serde_json::json!("/tmp/evil/escape.txt")),
874            ("content", serde_json::json!("pwned")),
875        ]);
876        let result = exec.execute_file_tool("write", &params);
877        assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
878        assert!(!Path::new("/tmp/evil/escape.txt").exists());
879    }
880
881    #[test]
882    fn find_path_filters_outside_sandbox() {
883        let sandbox = temp_dir();
884        let outside = temp_dir();
885        fs::write(outside.path().join("secret.rs"), "secret").unwrap();
886
887        let exec = FileExecutor::new(vec![sandbox.path().to_path_buf()]);
888        let pattern = format!("{}/*.rs", outside.path().display());
889        let params = make_params(&[("pattern", serde_json::json!(pattern))]);
890        let result = exec
891            .execute_file_tool("find_path", &params)
892            .unwrap()
893            .unwrap();
894        assert!(!result.summary.contains("secret.rs"));
895    }
896
897    #[tokio::test]
898    async fn tool_executor_execute_tool_call_delegates() {
899        let dir = temp_dir();
900        let file = dir.path().join("test.txt");
901        fs::write(&file, "content").unwrap();
902
903        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
904        let call = ToolCall {
905            tool_id: "read".to_owned(),
906            params: make_params(&[("path", serde_json::json!(file.to_str().unwrap()))]),
907        };
908        let result = exec.execute_tool_call(&call).await.unwrap().unwrap();
909        assert_eq!(result.tool_name, "read");
910        assert!(result.summary.contains("content"));
911    }
912
913    #[test]
914    fn tool_executor_tool_definitions_lists_all() {
915        let exec = FileExecutor::new(vec![]);
916        let defs = exec.tool_definitions();
917        let ids: Vec<&str> = defs.iter().map(|d| d.id.as_ref()).collect();
918        assert!(ids.contains(&"read"));
919        assert!(ids.contains(&"write"));
920        assert!(ids.contains(&"edit"));
921        assert!(ids.contains(&"find_path"));
922        assert!(ids.contains(&"grep"));
923        assert!(ids.contains(&"list_directory"));
924        assert!(ids.contains(&"create_directory"));
925        assert!(ids.contains(&"delete_path"));
926        assert!(ids.contains(&"move_path"));
927        assert!(ids.contains(&"copy_path"));
928        assert_eq!(defs.len(), 10);
929    }
930
931    #[test]
932    fn grep_relative_path_validated() {
933        let sandbox = temp_dir();
934        let exec = FileExecutor::new(vec![sandbox.path().to_path_buf()]);
935        let params = make_params(&[
936            ("pattern", serde_json::json!("password")),
937            ("path", serde_json::json!("../../etc")),
938        ]);
939        let result = exec.execute_file_tool("grep", &params);
940        assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
941    }
942
943    #[test]
944    fn tool_definitions_returns_ten_tools() {
945        let exec = FileExecutor::new(vec![]);
946        let defs = exec.tool_definitions();
947        assert_eq!(defs.len(), 10);
948        let ids: Vec<&str> = defs.iter().map(|d| d.id.as_ref()).collect();
949        assert_eq!(
950            ids,
951            vec![
952                "read",
953                "write",
954                "edit",
955                "find_path",
956                "grep",
957                "list_directory",
958                "create_directory",
959                "delete_path",
960                "move_path",
961                "copy_path",
962            ]
963        );
964    }
965
966    #[test]
967    fn tool_definitions_all_use_tool_call() {
968        let exec = FileExecutor::new(vec![]);
969        for def in exec.tool_definitions() {
970            assert_eq!(def.invocation, InvocationHint::ToolCall);
971        }
972    }
973
974    #[test]
975    fn tool_definitions_read_schema_has_params() {
976        let exec = FileExecutor::new(vec![]);
977        let defs = exec.tool_definitions();
978        let read = defs.iter().find(|d| d.id.as_ref() == "read").unwrap();
979        let obj = read.schema.as_object().unwrap();
980        let props = obj["properties"].as_object().unwrap();
981        assert!(props.contains_key("path"));
982        assert!(props.contains_key("offset"));
983        assert!(props.contains_key("limit"));
984    }
985
986    #[test]
987    fn missing_required_path_returns_invalid_params() {
988        let dir = temp_dir();
989        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
990        let params = serde_json::Map::new();
991        let result = exec.execute_file_tool("read", &params);
992        assert!(matches!(result, Err(ToolError::InvalidParams { .. })));
993    }
994
995    // --- list_directory tests ---
996
997    #[test]
998    fn list_directory_returns_entries() {
999        let dir = temp_dir();
1000        fs::write(dir.path().join("file.txt"), "").unwrap();
1001        fs::create_dir(dir.path().join("subdir")).unwrap();
1002
1003        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1004        let params = make_params(&[("path", serde_json::json!(dir.path().to_str().unwrap()))]);
1005        let result = exec
1006            .execute_file_tool("list_directory", &params)
1007            .unwrap()
1008            .unwrap();
1009        assert!(result.summary.contains("[dir]  subdir"));
1010        assert!(result.summary.contains("[file] file.txt"));
1011        // dirs listed before files
1012        let dir_pos = result.summary.find("[dir]").unwrap();
1013        let file_pos = result.summary.find("[file]").unwrap();
1014        assert!(dir_pos < file_pos);
1015    }
1016
1017    #[test]
1018    fn list_directory_empty_dir() {
1019        let dir = temp_dir();
1020        let subdir = dir.path().join("empty");
1021        fs::create_dir(&subdir).unwrap();
1022
1023        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1024        let params = make_params(&[("path", serde_json::json!(subdir.to_str().unwrap()))]);
1025        let result = exec
1026            .execute_file_tool("list_directory", &params)
1027            .unwrap()
1028            .unwrap();
1029        assert!(result.summary.contains("Empty directory"));
1030    }
1031
1032    #[test]
1033    fn list_directory_sandbox_violation() {
1034        let dir = temp_dir();
1035        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1036        let params = make_params(&[("path", serde_json::json!("/etc"))]);
1037        let result = exec.execute_file_tool("list_directory", &params);
1038        assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
1039    }
1040
1041    #[test]
1042    fn list_directory_nonexistent_returns_error() {
1043        let dir = temp_dir();
1044        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1045        let missing = dir.path().join("nonexistent");
1046        let params = make_params(&[("path", serde_json::json!(missing.to_str().unwrap()))]);
1047        let result = exec.execute_file_tool("list_directory", &params);
1048        assert!(result.is_err());
1049    }
1050
1051    #[test]
1052    fn list_directory_on_file_returns_error() {
1053        let dir = temp_dir();
1054        let file = dir.path().join("file.txt");
1055        fs::write(&file, "content").unwrap();
1056
1057        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1058        let params = make_params(&[("path", serde_json::json!(file.to_str().unwrap()))]);
1059        let result = exec.execute_file_tool("list_directory", &params);
1060        assert!(result.is_err());
1061    }
1062
1063    // --- create_directory tests ---
1064
1065    #[test]
1066    fn create_directory_creates_nested() {
1067        let dir = temp_dir();
1068        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1069        let nested = dir.path().join("a/b/c");
1070        let params = make_params(&[("path", serde_json::json!(nested.to_str().unwrap()))]);
1071        let result = exec
1072            .execute_file_tool("create_directory", &params)
1073            .unwrap()
1074            .unwrap();
1075        assert!(result.summary.contains("Created"));
1076        assert!(nested.is_dir());
1077    }
1078
1079    #[test]
1080    fn create_directory_sandbox_violation() {
1081        let dir = temp_dir();
1082        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1083        let params = make_params(&[("path", serde_json::json!("/tmp/evil_dir"))]);
1084        let result = exec.execute_file_tool("create_directory", &params);
1085        assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
1086    }
1087
1088    // --- delete_path tests ---
1089
1090    #[test]
1091    fn delete_path_file() {
1092        let dir = temp_dir();
1093        let file = dir.path().join("del.txt");
1094        fs::write(&file, "bye").unwrap();
1095
1096        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1097        let params = make_params(&[("path", serde_json::json!(file.to_str().unwrap()))]);
1098        exec.execute_file_tool("delete_path", &params)
1099            .unwrap()
1100            .unwrap();
1101        assert!(!file.exists());
1102    }
1103
1104    #[test]
1105    fn delete_path_empty_directory() {
1106        let dir = temp_dir();
1107        let subdir = dir.path().join("empty_sub");
1108        fs::create_dir(&subdir).unwrap();
1109
1110        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1111        let params = make_params(&[("path", serde_json::json!(subdir.to_str().unwrap()))]);
1112        exec.execute_file_tool("delete_path", &params)
1113            .unwrap()
1114            .unwrap();
1115        assert!(!subdir.exists());
1116    }
1117
1118    #[test]
1119    fn delete_path_non_empty_dir_without_recursive_fails() {
1120        let dir = temp_dir();
1121        let subdir = dir.path().join("nonempty");
1122        fs::create_dir(&subdir).unwrap();
1123        fs::write(subdir.join("file.txt"), "x").unwrap();
1124
1125        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1126        let params = make_params(&[("path", serde_json::json!(subdir.to_str().unwrap()))]);
1127        let result = exec.execute_file_tool("delete_path", &params);
1128        assert!(result.is_err());
1129    }
1130
1131    #[test]
1132    fn delete_path_recursive() {
1133        let dir = temp_dir();
1134        let subdir = dir.path().join("recurse");
1135        fs::create_dir(&subdir).unwrap();
1136        fs::write(subdir.join("f.txt"), "x").unwrap();
1137
1138        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1139        let params = make_params(&[
1140            ("path", serde_json::json!(subdir.to_str().unwrap())),
1141            ("recursive", serde_json::json!(true)),
1142        ]);
1143        exec.execute_file_tool("delete_path", &params)
1144            .unwrap()
1145            .unwrap();
1146        assert!(!subdir.exists());
1147    }
1148
1149    #[test]
1150    fn delete_path_sandbox_violation() {
1151        let dir = temp_dir();
1152        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1153        let params = make_params(&[("path", serde_json::json!("/etc/hosts"))]);
1154        let result = exec.execute_file_tool("delete_path", &params);
1155        assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
1156    }
1157
1158    #[test]
1159    fn delete_path_refuses_sandbox_root() {
1160        let dir = temp_dir();
1161        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1162        let params = make_params(&[
1163            ("path", serde_json::json!(dir.path().to_str().unwrap())),
1164            ("recursive", serde_json::json!(true)),
1165        ]);
1166        let result = exec.execute_file_tool("delete_path", &params);
1167        assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
1168    }
1169
1170    // --- move_path tests ---
1171
1172    #[test]
1173    fn move_path_renames_file() {
1174        let dir = temp_dir();
1175        let src = dir.path().join("src.txt");
1176        let dst = dir.path().join("dst.txt");
1177        fs::write(&src, "data").unwrap();
1178
1179        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1180        let params = make_params(&[
1181            ("source", serde_json::json!(src.to_str().unwrap())),
1182            ("destination", serde_json::json!(dst.to_str().unwrap())),
1183        ]);
1184        exec.execute_file_tool("move_path", &params)
1185            .unwrap()
1186            .unwrap();
1187        assert!(!src.exists());
1188        assert_eq!(fs::read_to_string(&dst).unwrap(), "data");
1189    }
1190
1191    #[test]
1192    fn move_path_cross_sandbox_denied() {
1193        let sandbox = temp_dir();
1194        let outside = temp_dir();
1195        let src = sandbox.path().join("src.txt");
1196        fs::write(&src, "x").unwrap();
1197
1198        let exec = FileExecutor::new(vec![sandbox.path().to_path_buf()]);
1199        let dst = outside.path().join("dst.txt");
1200        let params = make_params(&[
1201            ("source", serde_json::json!(src.to_str().unwrap())),
1202            ("destination", serde_json::json!(dst.to_str().unwrap())),
1203        ]);
1204        let result = exec.execute_file_tool("move_path", &params);
1205        assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
1206    }
1207
1208    // --- copy_path tests ---
1209
1210    #[test]
1211    fn copy_path_file() {
1212        let dir = temp_dir();
1213        let src = dir.path().join("src.txt");
1214        let dst = dir.path().join("dst.txt");
1215        fs::write(&src, "hello").unwrap();
1216
1217        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1218        let params = make_params(&[
1219            ("source", serde_json::json!(src.to_str().unwrap())),
1220            ("destination", serde_json::json!(dst.to_str().unwrap())),
1221        ]);
1222        exec.execute_file_tool("copy_path", &params)
1223            .unwrap()
1224            .unwrap();
1225        assert_eq!(fs::read_to_string(&src).unwrap(), "hello");
1226        assert_eq!(fs::read_to_string(&dst).unwrap(), "hello");
1227    }
1228
1229    #[test]
1230    fn copy_path_directory_recursive() {
1231        let dir = temp_dir();
1232        let src_dir = dir.path().join("src_dir");
1233        fs::create_dir(&src_dir).unwrap();
1234        fs::write(src_dir.join("a.txt"), "aaa").unwrap();
1235
1236        let dst_dir = dir.path().join("dst_dir");
1237
1238        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1239        let params = make_params(&[
1240            ("source", serde_json::json!(src_dir.to_str().unwrap())),
1241            ("destination", serde_json::json!(dst_dir.to_str().unwrap())),
1242        ]);
1243        exec.execute_file_tool("copy_path", &params)
1244            .unwrap()
1245            .unwrap();
1246        assert_eq!(fs::read_to_string(dst_dir.join("a.txt")).unwrap(), "aaa");
1247    }
1248
1249    #[test]
1250    fn copy_path_sandbox_violation() {
1251        let sandbox = temp_dir();
1252        let outside = temp_dir();
1253        let src = sandbox.path().join("src.txt");
1254        fs::write(&src, "x").unwrap();
1255
1256        let exec = FileExecutor::new(vec![sandbox.path().to_path_buf()]);
1257        let dst = outside.path().join("dst.txt");
1258        let params = make_params(&[
1259            ("source", serde_json::json!(src.to_str().unwrap())),
1260            ("destination", serde_json::json!(dst.to_str().unwrap())),
1261        ]);
1262        let result = exec.execute_file_tool("copy_path", &params);
1263        assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
1264    }
1265
1266    // CR-11: invalid glob pattern returns error
1267    #[test]
1268    fn find_path_invalid_pattern_returns_error() {
1269        let dir = temp_dir();
1270        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1271        let params = make_params(&[("pattern", serde_json::json!("[invalid"))]);
1272        let result = exec.execute_file_tool("find_path", &params);
1273        assert!(result.is_err());
1274    }
1275
1276    // CR-12: create_directory is idempotent on existing dir
1277    #[test]
1278    fn create_directory_idempotent() {
1279        let dir = temp_dir();
1280        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1281        let target = dir.path().join("exists");
1282        fs::create_dir(&target).unwrap();
1283
1284        let params = make_params(&[("path", serde_json::json!(target.to_str().unwrap()))]);
1285        let result = exec.execute_file_tool("create_directory", &params);
1286        assert!(result.is_ok());
1287        assert!(target.is_dir());
1288    }
1289
1290    // CR-13: move_path source sandbox violation
1291    #[test]
1292    fn move_path_source_sandbox_violation() {
1293        let sandbox = temp_dir();
1294        let outside = temp_dir();
1295        let src = outside.path().join("src.txt");
1296        fs::write(&src, "x").unwrap();
1297
1298        let exec = FileExecutor::new(vec![sandbox.path().to_path_buf()]);
1299        let dst = sandbox.path().join("dst.txt");
1300        let params = make_params(&[
1301            ("source", serde_json::json!(src.to_str().unwrap())),
1302            ("destination", serde_json::json!(dst.to_str().unwrap())),
1303        ]);
1304        let result = exec.execute_file_tool("move_path", &params);
1305        assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
1306    }
1307
1308    // CR-13: copy_path source sandbox violation
1309    #[test]
1310    fn copy_path_source_sandbox_violation() {
1311        let sandbox = temp_dir();
1312        let outside = temp_dir();
1313        let src = outside.path().join("src.txt");
1314        fs::write(&src, "x").unwrap();
1315
1316        let exec = FileExecutor::new(vec![sandbox.path().to_path_buf()]);
1317        let dst = sandbox.path().join("dst.txt");
1318        let params = make_params(&[
1319            ("source", serde_json::json!(src.to_str().unwrap())),
1320            ("destination", serde_json::json!(dst.to_str().unwrap())),
1321        ]);
1322        let result = exec.execute_file_tool("copy_path", &params);
1323        assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
1324    }
1325
1326    // CR-01: copy_dir_recursive skips symlinks
1327    #[cfg(unix)]
1328    #[test]
1329    fn copy_dir_skips_symlinks() {
1330        let dir = temp_dir();
1331        let src_dir = dir.path().join("src");
1332        fs::create_dir(&src_dir).unwrap();
1333        fs::write(src_dir.join("real.txt"), "real").unwrap();
1334
1335        // Create a symlink inside src pointing outside sandbox
1336        let outside = temp_dir();
1337        std::os::unix::fs::symlink(outside.path(), src_dir.join("link")).unwrap();
1338
1339        let dst_dir = dir.path().join("dst");
1340        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1341        let params = make_params(&[
1342            ("source", serde_json::json!(src_dir.to_str().unwrap())),
1343            ("destination", serde_json::json!(dst_dir.to_str().unwrap())),
1344        ]);
1345        exec.execute_file_tool("copy_path", &params)
1346            .unwrap()
1347            .unwrap();
1348        // Real file copied
1349        assert_eq!(
1350            fs::read_to_string(dst_dir.join("real.txt")).unwrap(),
1351            "real"
1352        );
1353        // Symlink not copied
1354        assert!(!dst_dir.join("link").exists());
1355    }
1356
1357    // CR-04: list_directory detects symlinks
1358    #[cfg(unix)]
1359    #[test]
1360    fn list_directory_shows_symlinks() {
1361        let dir = temp_dir();
1362        let target = dir.path().join("target.txt");
1363        fs::write(&target, "x").unwrap();
1364        std::os::unix::fs::symlink(&target, dir.path().join("link")).unwrap();
1365
1366        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1367        let params = make_params(&[("path", serde_json::json!(dir.path().to_str().unwrap()))]);
1368        let result = exec
1369            .execute_file_tool("list_directory", &params)
1370            .unwrap()
1371            .unwrap();
1372        assert!(result.summary.contains("[symlink] link"));
1373        assert!(result.summary.contains("[file] target.txt"));
1374    }
1375
1376    #[test]
1377    fn tilde_path_is_expanded() {
1378        let exec = FileExecutor::new(vec![PathBuf::from("~/nonexistent_subdir_for_test")]);
1379        assert!(
1380            !exec.allowed_paths[0].to_string_lossy().starts_with('~'),
1381            "tilde was not expanded: {:?}",
1382            exec.allowed_paths[0]
1383        );
1384    }
1385
1386    #[test]
1387    fn absolute_path_unchanged() {
1388        let exec = FileExecutor::new(vec![PathBuf::from("/tmp")]);
1389        // On macOS /tmp is a symlink to /private/tmp; canonicalize resolves it.
1390        // The invariant is that the result is absolute and tilde-free.
1391        let p = exec.allowed_paths[0].to_string_lossy();
1392        assert!(
1393            p.starts_with('/'),
1394            "expected absolute path, got: {:?}",
1395            exec.allowed_paths[0]
1396        );
1397        assert!(
1398            !p.starts_with('~'),
1399            "tilde must not appear in result: {:?}",
1400            exec.allowed_paths[0]
1401        );
1402    }
1403
1404    #[test]
1405    fn tilde_only_expands_to_home() {
1406        let exec = FileExecutor::new(vec![PathBuf::from("~")]);
1407        assert!(
1408            !exec.allowed_paths[0].to_string_lossy().starts_with('~'),
1409            "bare tilde was not expanded: {:?}",
1410            exec.allowed_paths[0]
1411        );
1412    }
1413
1414    #[test]
1415    fn empty_allowed_paths_uses_cwd() {
1416        let exec = FileExecutor::new(vec![]);
1417        assert!(
1418            !exec.allowed_paths.is_empty(),
1419            "expected cwd fallback, got empty allowed_paths"
1420        );
1421    }
1422
1423    // --- normalize_path tests ---
1424
1425    #[test]
1426    fn normalize_path_normal_path() {
1427        assert_eq!(
1428            normalize_path(Path::new("/tmp/sandbox/file.txt")),
1429            PathBuf::from("/tmp/sandbox/file.txt")
1430        );
1431    }
1432
1433    #[test]
1434    fn normalize_path_collapses_dot() {
1435        assert_eq!(
1436            normalize_path(Path::new("/tmp/sandbox/./file.txt")),
1437            PathBuf::from("/tmp/sandbox/file.txt")
1438        );
1439    }
1440
1441    #[test]
1442    fn normalize_path_collapses_dotdot() {
1443        assert_eq!(
1444            normalize_path(Path::new("/tmp/sandbox/nonexistent/../../etc/passwd")),
1445            PathBuf::from("/tmp/etc/passwd")
1446        );
1447    }
1448
1449    #[test]
1450    fn normalize_path_nested_dotdot() {
1451        assert_eq!(
1452            normalize_path(Path::new("/tmp/sandbox/a/b/../../../etc/passwd")),
1453            PathBuf::from("/tmp/etc/passwd")
1454        );
1455    }
1456
1457    #[test]
1458    fn normalize_path_at_sandbox_boundary() {
1459        assert_eq!(
1460            normalize_path(Path::new("/tmp/sandbox")),
1461            PathBuf::from("/tmp/sandbox")
1462        );
1463    }
1464
1465    // --- validate_path dotdot bypass tests ---
1466
1467    #[test]
1468    fn validate_path_dotdot_bypass_nonexistent_blocked() {
1469        let dir = temp_dir();
1470        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1471        // /sandbox/nonexistent/../../etc/passwd normalizes to /etc/passwd — must be blocked
1472        let escape = format!("{}/nonexistent/../../etc/passwd", dir.path().display());
1473        let params = make_params(&[("path", serde_json::json!(escape))]);
1474        let result = exec.execute_file_tool("read", &params);
1475        assert!(
1476            matches!(result, Err(ToolError::SandboxViolation { .. })),
1477            "expected SandboxViolation for dotdot bypass, got {:?}",
1478            result
1479        );
1480    }
1481
1482    #[test]
1483    fn validate_path_dotdot_nested_bypass_blocked() {
1484        let dir = temp_dir();
1485        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1486        let escape = format!("{}/a/b/../../../etc/shadow", dir.path().display());
1487        let params = make_params(&[("path", serde_json::json!(escape))]);
1488        let result = exec.execute_file_tool("read", &params);
1489        assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
1490    }
1491
1492    #[test]
1493    fn validate_path_inside_sandbox_passes() {
1494        let dir = temp_dir();
1495        let file = dir.path().join("allowed.txt");
1496        fs::write(&file, "ok").unwrap();
1497        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1498        let params = make_params(&[("path", serde_json::json!(file.to_str().unwrap()))]);
1499        let result = exec.execute_file_tool("read", &params);
1500        assert!(result.is_ok());
1501    }
1502
1503    #[test]
1504    fn validate_path_dot_components_inside_sandbox_passes() {
1505        let dir = temp_dir();
1506        let file = dir.path().join("sub/file.txt");
1507        fs::create_dir_all(dir.path().join("sub")).unwrap();
1508        fs::write(&file, "ok").unwrap();
1509        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1510        let dotpath = format!("{}/sub/./file.txt", dir.path().display());
1511        let params = make_params(&[("path", serde_json::json!(dotpath))]);
1512        let result = exec.execute_file_tool("read", &params);
1513        assert!(result.is_ok());
1514    }
1515}