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