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            tool_call_id: String::new(),
1036        };
1037        let result = exec.execute_tool_call(&call).await.unwrap().unwrap();
1038        assert_eq!(result.tool_name, "read");
1039        assert!(result.summary.contains("content"));
1040    }
1041
1042    #[test]
1043    fn tool_executor_tool_definitions_lists_all() {
1044        let exec = FileExecutor::new(vec![]);
1045        let defs = exec.tool_definitions();
1046        let ids: Vec<&str> = defs.iter().map(|d| d.id.as_ref()).collect();
1047        assert!(ids.contains(&"read"));
1048        assert!(ids.contains(&"write"));
1049        assert!(ids.contains(&"edit"));
1050        assert!(ids.contains(&"find_path"));
1051        assert!(ids.contains(&"grep"));
1052        assert!(ids.contains(&"list_directory"));
1053        assert!(ids.contains(&"create_directory"));
1054        assert!(ids.contains(&"delete_path"));
1055        assert!(ids.contains(&"move_path"));
1056        assert!(ids.contains(&"copy_path"));
1057        assert_eq!(defs.len(), 10);
1058    }
1059
1060    #[test]
1061    fn grep_relative_path_validated() {
1062        let sandbox = temp_dir();
1063        let exec = FileExecutor::new(vec![sandbox.path().to_path_buf()]);
1064        let params = make_params(&[
1065            ("pattern", serde_json::json!("password")),
1066            ("path", serde_json::json!("../../etc")),
1067        ]);
1068        let result = exec.execute_file_tool("grep", &params);
1069        assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
1070    }
1071
1072    #[test]
1073    fn tool_definitions_returns_ten_tools() {
1074        let exec = FileExecutor::new(vec![]);
1075        let defs = exec.tool_definitions();
1076        assert_eq!(defs.len(), 10);
1077        let ids: Vec<&str> = defs.iter().map(|d| d.id.as_ref()).collect();
1078        assert_eq!(
1079            ids,
1080            vec![
1081                "read",
1082                "write",
1083                "edit",
1084                "find_path",
1085                "grep",
1086                "list_directory",
1087                "create_directory",
1088                "delete_path",
1089                "move_path",
1090                "copy_path",
1091            ]
1092        );
1093    }
1094
1095    #[test]
1096    fn tool_definitions_all_use_tool_call() {
1097        let exec = FileExecutor::new(vec![]);
1098        for def in exec.tool_definitions() {
1099            assert_eq!(def.invocation, InvocationHint::ToolCall);
1100        }
1101    }
1102
1103    #[test]
1104    fn tool_definitions_read_schema_has_params() {
1105        let exec = FileExecutor::new(vec![]);
1106        let defs = exec.tool_definitions();
1107        let read = defs.iter().find(|d| d.id.as_ref() == "read").unwrap();
1108        let obj = read.schema.as_object().unwrap();
1109        let props = obj["properties"].as_object().unwrap();
1110        assert!(props.contains_key("path"));
1111        assert!(props.contains_key("offset"));
1112        assert!(props.contains_key("limit"));
1113    }
1114
1115    #[test]
1116    fn missing_required_path_returns_invalid_params() {
1117        let dir = temp_dir();
1118        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1119        let params = serde_json::Map::new();
1120        let result = exec.execute_file_tool("read", &params);
1121        assert!(matches!(result, Err(ToolError::InvalidParams { .. })));
1122    }
1123
1124    // --- list_directory tests ---
1125
1126    #[test]
1127    fn list_directory_returns_entries() {
1128        let dir = temp_dir();
1129        fs::write(dir.path().join("file.txt"), "").unwrap();
1130        fs::create_dir(dir.path().join("subdir")).unwrap();
1131
1132        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1133        let params = make_params(&[("path", serde_json::json!(dir.path().to_str().unwrap()))]);
1134        let result = exec
1135            .execute_file_tool("list_directory", &params)
1136            .unwrap()
1137            .unwrap();
1138        assert!(result.summary.contains("[dir]  subdir"));
1139        assert!(result.summary.contains("[file] file.txt"));
1140        // dirs listed before files
1141        let dir_pos = result.summary.find("[dir]").unwrap();
1142        let file_pos = result.summary.find("[file]").unwrap();
1143        assert!(dir_pos < file_pos);
1144    }
1145
1146    #[test]
1147    fn list_directory_empty_dir() {
1148        let dir = temp_dir();
1149        let subdir = dir.path().join("empty");
1150        fs::create_dir(&subdir).unwrap();
1151
1152        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1153        let params = make_params(&[("path", serde_json::json!(subdir.to_str().unwrap()))]);
1154        let result = exec
1155            .execute_file_tool("list_directory", &params)
1156            .unwrap()
1157            .unwrap();
1158        assert!(result.summary.contains("Empty directory"));
1159    }
1160
1161    #[test]
1162    fn list_directory_sandbox_violation() {
1163        let dir = temp_dir();
1164        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1165        let params = make_params(&[("path", serde_json::json!("/etc"))]);
1166        let result = exec.execute_file_tool("list_directory", &params);
1167        assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
1168    }
1169
1170    #[test]
1171    fn list_directory_nonexistent_returns_error() {
1172        let dir = temp_dir();
1173        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1174        let missing = dir.path().join("nonexistent");
1175        let params = make_params(&[("path", serde_json::json!(missing.to_str().unwrap()))]);
1176        let result = exec.execute_file_tool("list_directory", &params);
1177        assert!(result.is_err());
1178    }
1179
1180    #[test]
1181    fn list_directory_on_file_returns_error() {
1182        let dir = temp_dir();
1183        let file = dir.path().join("file.txt");
1184        fs::write(&file, "content").unwrap();
1185
1186        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1187        let params = make_params(&[("path", serde_json::json!(file.to_str().unwrap()))]);
1188        let result = exec.execute_file_tool("list_directory", &params);
1189        assert!(result.is_err());
1190    }
1191
1192    // --- create_directory tests ---
1193
1194    #[test]
1195    fn create_directory_creates_nested() {
1196        let dir = temp_dir();
1197        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1198        let nested = dir.path().join("a/b/c");
1199        let params = make_params(&[("path", serde_json::json!(nested.to_str().unwrap()))]);
1200        let result = exec
1201            .execute_file_tool("create_directory", &params)
1202            .unwrap()
1203            .unwrap();
1204        assert!(result.summary.contains("Created"));
1205        assert!(nested.is_dir());
1206    }
1207
1208    #[test]
1209    fn create_directory_sandbox_violation() {
1210        let dir = temp_dir();
1211        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1212        let params = make_params(&[("path", serde_json::json!("/tmp/evil_dir"))]);
1213        let result = exec.execute_file_tool("create_directory", &params);
1214        assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
1215    }
1216
1217    // --- delete_path tests ---
1218
1219    #[test]
1220    fn delete_path_file() {
1221        let dir = temp_dir();
1222        let file = dir.path().join("del.txt");
1223        fs::write(&file, "bye").unwrap();
1224
1225        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1226        let params = make_params(&[("path", serde_json::json!(file.to_str().unwrap()))]);
1227        exec.execute_file_tool("delete_path", &params)
1228            .unwrap()
1229            .unwrap();
1230        assert!(!file.exists());
1231    }
1232
1233    #[test]
1234    fn delete_path_empty_directory() {
1235        let dir = temp_dir();
1236        let subdir = dir.path().join("empty_sub");
1237        fs::create_dir(&subdir).unwrap();
1238
1239        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1240        let params = make_params(&[("path", serde_json::json!(subdir.to_str().unwrap()))]);
1241        exec.execute_file_tool("delete_path", &params)
1242            .unwrap()
1243            .unwrap();
1244        assert!(!subdir.exists());
1245    }
1246
1247    #[test]
1248    fn delete_path_non_empty_dir_without_recursive_fails() {
1249        let dir = temp_dir();
1250        let subdir = dir.path().join("nonempty");
1251        fs::create_dir(&subdir).unwrap();
1252        fs::write(subdir.join("file.txt"), "x").unwrap();
1253
1254        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1255        let params = make_params(&[("path", serde_json::json!(subdir.to_str().unwrap()))]);
1256        let result = exec.execute_file_tool("delete_path", &params);
1257        assert!(result.is_err());
1258    }
1259
1260    #[test]
1261    fn delete_path_recursive() {
1262        let dir = temp_dir();
1263        let subdir = dir.path().join("recurse");
1264        fs::create_dir(&subdir).unwrap();
1265        fs::write(subdir.join("f.txt"), "x").unwrap();
1266
1267        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1268        let params = make_params(&[
1269            ("path", serde_json::json!(subdir.to_str().unwrap())),
1270            ("recursive", serde_json::json!(true)),
1271        ]);
1272        exec.execute_file_tool("delete_path", &params)
1273            .unwrap()
1274            .unwrap();
1275        assert!(!subdir.exists());
1276    }
1277
1278    #[test]
1279    fn delete_path_sandbox_violation() {
1280        let dir = temp_dir();
1281        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1282        let params = make_params(&[("path", serde_json::json!("/etc/hosts"))]);
1283        let result = exec.execute_file_tool("delete_path", &params);
1284        assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
1285    }
1286
1287    #[test]
1288    fn delete_path_refuses_sandbox_root() {
1289        let dir = temp_dir();
1290        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1291        let params = make_params(&[
1292            ("path", serde_json::json!(dir.path().to_str().unwrap())),
1293            ("recursive", serde_json::json!(true)),
1294        ]);
1295        let result = exec.execute_file_tool("delete_path", &params);
1296        assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
1297    }
1298
1299    // --- move_path tests ---
1300
1301    #[test]
1302    fn move_path_renames_file() {
1303        let dir = temp_dir();
1304        let src = dir.path().join("src.txt");
1305        let dst = dir.path().join("dst.txt");
1306        fs::write(&src, "data").unwrap();
1307
1308        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1309        let params = make_params(&[
1310            ("source", serde_json::json!(src.to_str().unwrap())),
1311            ("destination", serde_json::json!(dst.to_str().unwrap())),
1312        ]);
1313        exec.execute_file_tool("move_path", &params)
1314            .unwrap()
1315            .unwrap();
1316        assert!(!src.exists());
1317        assert_eq!(fs::read_to_string(&dst).unwrap(), "data");
1318    }
1319
1320    #[test]
1321    fn move_path_cross_sandbox_denied() {
1322        let sandbox = temp_dir();
1323        let outside = temp_dir();
1324        let src = sandbox.path().join("src.txt");
1325        fs::write(&src, "x").unwrap();
1326
1327        let exec = FileExecutor::new(vec![sandbox.path().to_path_buf()]);
1328        let dst = outside.path().join("dst.txt");
1329        let params = make_params(&[
1330            ("source", serde_json::json!(src.to_str().unwrap())),
1331            ("destination", serde_json::json!(dst.to_str().unwrap())),
1332        ]);
1333        let result = exec.execute_file_tool("move_path", &params);
1334        assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
1335    }
1336
1337    // --- copy_path tests ---
1338
1339    #[test]
1340    fn copy_path_file() {
1341        let dir = temp_dir();
1342        let src = dir.path().join("src.txt");
1343        let dst = dir.path().join("dst.txt");
1344        fs::write(&src, "hello").unwrap();
1345
1346        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1347        let params = make_params(&[
1348            ("source", serde_json::json!(src.to_str().unwrap())),
1349            ("destination", serde_json::json!(dst.to_str().unwrap())),
1350        ]);
1351        exec.execute_file_tool("copy_path", &params)
1352            .unwrap()
1353            .unwrap();
1354        assert_eq!(fs::read_to_string(&src).unwrap(), "hello");
1355        assert_eq!(fs::read_to_string(&dst).unwrap(), "hello");
1356    }
1357
1358    #[test]
1359    fn copy_path_directory_recursive() {
1360        let dir = temp_dir();
1361        let src_dir = dir.path().join("src_dir");
1362        fs::create_dir(&src_dir).unwrap();
1363        fs::write(src_dir.join("a.txt"), "aaa").unwrap();
1364
1365        let dst_dir = dir.path().join("dst_dir");
1366
1367        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1368        let params = make_params(&[
1369            ("source", serde_json::json!(src_dir.to_str().unwrap())),
1370            ("destination", serde_json::json!(dst_dir.to_str().unwrap())),
1371        ]);
1372        exec.execute_file_tool("copy_path", &params)
1373            .unwrap()
1374            .unwrap();
1375        assert_eq!(fs::read_to_string(dst_dir.join("a.txt")).unwrap(), "aaa");
1376    }
1377
1378    #[test]
1379    fn copy_path_sandbox_violation() {
1380        let sandbox = temp_dir();
1381        let outside = temp_dir();
1382        let src = sandbox.path().join("src.txt");
1383        fs::write(&src, "x").unwrap();
1384
1385        let exec = FileExecutor::new(vec![sandbox.path().to_path_buf()]);
1386        let dst = outside.path().join("dst.txt");
1387        let params = make_params(&[
1388            ("source", serde_json::json!(src.to_str().unwrap())),
1389            ("destination", serde_json::json!(dst.to_str().unwrap())),
1390        ]);
1391        let result = exec.execute_file_tool("copy_path", &params);
1392        assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
1393    }
1394
1395    // CR-11: invalid glob pattern returns error
1396    #[test]
1397    fn find_path_invalid_pattern_returns_error() {
1398        let dir = temp_dir();
1399        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1400        let params = make_params(&[("pattern", serde_json::json!("[invalid"))]);
1401        let result = exec.execute_file_tool("find_path", &params);
1402        assert!(result.is_err());
1403    }
1404
1405    // CR-12: create_directory is idempotent on existing dir
1406    #[test]
1407    fn create_directory_idempotent() {
1408        let dir = temp_dir();
1409        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1410        let target = dir.path().join("exists");
1411        fs::create_dir(&target).unwrap();
1412
1413        let params = make_params(&[("path", serde_json::json!(target.to_str().unwrap()))]);
1414        let result = exec.execute_file_tool("create_directory", &params);
1415        assert!(result.is_ok());
1416        assert!(target.is_dir());
1417    }
1418
1419    // CR-13: move_path source sandbox violation
1420    #[test]
1421    fn move_path_source_sandbox_violation() {
1422        let sandbox = temp_dir();
1423        let outside = temp_dir();
1424        let src = outside.path().join("src.txt");
1425        fs::write(&src, "x").unwrap();
1426
1427        let exec = FileExecutor::new(vec![sandbox.path().to_path_buf()]);
1428        let dst = sandbox.path().join("dst.txt");
1429        let params = make_params(&[
1430            ("source", serde_json::json!(src.to_str().unwrap())),
1431            ("destination", serde_json::json!(dst.to_str().unwrap())),
1432        ]);
1433        let result = exec.execute_file_tool("move_path", &params);
1434        assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
1435    }
1436
1437    // CR-13: copy_path source sandbox violation
1438    #[test]
1439    fn copy_path_source_sandbox_violation() {
1440        let sandbox = temp_dir();
1441        let outside = temp_dir();
1442        let src = outside.path().join("src.txt");
1443        fs::write(&src, "x").unwrap();
1444
1445        let exec = FileExecutor::new(vec![sandbox.path().to_path_buf()]);
1446        let dst = sandbox.path().join("dst.txt");
1447        let params = make_params(&[
1448            ("source", serde_json::json!(src.to_str().unwrap())),
1449            ("destination", serde_json::json!(dst.to_str().unwrap())),
1450        ]);
1451        let result = exec.execute_file_tool("copy_path", &params);
1452        assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
1453    }
1454
1455    // CR-01: copy_dir_recursive skips symlinks
1456    #[cfg(unix)]
1457    #[test]
1458    fn copy_dir_skips_symlinks() {
1459        let dir = temp_dir();
1460        let src_dir = dir.path().join("src");
1461        fs::create_dir(&src_dir).unwrap();
1462        fs::write(src_dir.join("real.txt"), "real").unwrap();
1463
1464        // Create a symlink inside src pointing outside sandbox
1465        let outside = temp_dir();
1466        std::os::unix::fs::symlink(outside.path(), src_dir.join("link")).unwrap();
1467
1468        let dst_dir = dir.path().join("dst");
1469        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1470        let params = make_params(&[
1471            ("source", serde_json::json!(src_dir.to_str().unwrap())),
1472            ("destination", serde_json::json!(dst_dir.to_str().unwrap())),
1473        ]);
1474        exec.execute_file_tool("copy_path", &params)
1475            .unwrap()
1476            .unwrap();
1477        // Real file copied
1478        assert_eq!(
1479            fs::read_to_string(dst_dir.join("real.txt")).unwrap(),
1480            "real"
1481        );
1482        // Symlink not copied
1483        assert!(!dst_dir.join("link").exists());
1484    }
1485
1486    // CR-04: list_directory detects symlinks
1487    #[cfg(unix)]
1488    #[test]
1489    fn list_directory_shows_symlinks() {
1490        let dir = temp_dir();
1491        let target = dir.path().join("target.txt");
1492        fs::write(&target, "x").unwrap();
1493        std::os::unix::fs::symlink(&target, dir.path().join("link")).unwrap();
1494
1495        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1496        let params = make_params(&[("path", serde_json::json!(dir.path().to_str().unwrap()))]);
1497        let result = exec
1498            .execute_file_tool("list_directory", &params)
1499            .unwrap()
1500            .unwrap();
1501        assert!(result.summary.contains("[symlink] link"));
1502        assert!(result.summary.contains("[file] target.txt"));
1503    }
1504
1505    #[test]
1506    fn tilde_path_is_expanded() {
1507        let exec = FileExecutor::new(vec![PathBuf::from("~/nonexistent_subdir_for_test")]);
1508        assert!(
1509            !exec.allowed_paths[0].to_string_lossy().starts_with('~'),
1510            "tilde was not expanded: {:?}",
1511            exec.allowed_paths[0]
1512        );
1513    }
1514
1515    #[test]
1516    fn absolute_path_unchanged() {
1517        let exec = FileExecutor::new(vec![PathBuf::from("/tmp")]);
1518        // On macOS /tmp is a symlink to /private/tmp; canonicalize resolves it.
1519        // The invariant is that the result is absolute and tilde-free.
1520        let p = exec.allowed_paths[0].to_string_lossy();
1521        assert!(
1522            p.starts_with('/'),
1523            "expected absolute path, got: {:?}",
1524            exec.allowed_paths[0]
1525        );
1526        assert!(
1527            !p.starts_with('~'),
1528            "tilde must not appear in result: {:?}",
1529            exec.allowed_paths[0]
1530        );
1531    }
1532
1533    #[test]
1534    fn tilde_only_expands_to_home() {
1535        let exec = FileExecutor::new(vec![PathBuf::from("~")]);
1536        assert!(
1537            !exec.allowed_paths[0].to_string_lossy().starts_with('~'),
1538            "bare tilde was not expanded: {:?}",
1539            exec.allowed_paths[0]
1540        );
1541    }
1542
1543    #[test]
1544    fn empty_allowed_paths_uses_cwd() {
1545        let exec = FileExecutor::new(vec![]);
1546        assert!(
1547            !exec.allowed_paths.is_empty(),
1548            "expected cwd fallback, got empty allowed_paths"
1549        );
1550    }
1551
1552    // --- normalize_path tests ---
1553
1554    #[test]
1555    fn normalize_path_normal_path() {
1556        assert_eq!(
1557            normalize_path(Path::new("/tmp/sandbox/file.txt")),
1558            PathBuf::from("/tmp/sandbox/file.txt")
1559        );
1560    }
1561
1562    #[test]
1563    fn normalize_path_collapses_dot() {
1564        assert_eq!(
1565            normalize_path(Path::new("/tmp/sandbox/./file.txt")),
1566            PathBuf::from("/tmp/sandbox/file.txt")
1567        );
1568    }
1569
1570    #[test]
1571    fn normalize_path_collapses_dotdot() {
1572        assert_eq!(
1573            normalize_path(Path::new("/tmp/sandbox/nonexistent/../../etc/passwd")),
1574            PathBuf::from("/tmp/etc/passwd")
1575        );
1576    }
1577
1578    #[test]
1579    fn normalize_path_nested_dotdot() {
1580        assert_eq!(
1581            normalize_path(Path::new("/tmp/sandbox/a/b/../../../etc/passwd")),
1582            PathBuf::from("/tmp/etc/passwd")
1583        );
1584    }
1585
1586    #[test]
1587    fn normalize_path_at_sandbox_boundary() {
1588        assert_eq!(
1589            normalize_path(Path::new("/tmp/sandbox")),
1590            PathBuf::from("/tmp/sandbox")
1591        );
1592    }
1593
1594    // --- validate_path dotdot bypass tests ---
1595
1596    #[test]
1597    fn validate_path_dotdot_bypass_nonexistent_blocked() {
1598        let dir = temp_dir();
1599        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1600        // /sandbox/nonexistent/../../etc/passwd normalizes to /etc/passwd — must be blocked
1601        let escape = format!("{}/nonexistent/../../etc/passwd", dir.path().display());
1602        let params = make_params(&[("path", serde_json::json!(escape))]);
1603        let result = exec.execute_file_tool("read", &params);
1604        assert!(
1605            matches!(result, Err(ToolError::SandboxViolation { .. })),
1606            "expected SandboxViolation for dotdot bypass, got {result:?}"
1607        );
1608    }
1609
1610    #[test]
1611    fn validate_path_dotdot_nested_bypass_blocked() {
1612        let dir = temp_dir();
1613        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1614        let escape = format!("{}/a/b/../../../etc/shadow", dir.path().display());
1615        let params = make_params(&[("path", serde_json::json!(escape))]);
1616        let result = exec.execute_file_tool("read", &params);
1617        assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
1618    }
1619
1620    #[test]
1621    fn validate_path_inside_sandbox_passes() {
1622        let dir = temp_dir();
1623        let file = dir.path().join("allowed.txt");
1624        fs::write(&file, "ok").unwrap();
1625        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1626        let params = make_params(&[("path", serde_json::json!(file.to_str().unwrap()))]);
1627        let result = exec.execute_file_tool("read", &params);
1628        assert!(result.is_ok());
1629    }
1630
1631    #[test]
1632    fn validate_path_dot_components_inside_sandbox_passes() {
1633        let dir = temp_dir();
1634        let file = dir.path().join("sub/file.txt");
1635        fs::create_dir_all(dir.path().join("sub")).unwrap();
1636        fs::write(&file, "ok").unwrap();
1637        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1638        let dotpath = format!("{}/sub/./file.txt", dir.path().display());
1639        let params = make_params(&[("path", serde_json::json!(dotpath))]);
1640        let result = exec.execute_file_tool("read", &params);
1641        assert!(result.is_ok());
1642    }
1643
1644    // --- #2489: per-path read allow/deny sandbox tests ---
1645
1646    #[test]
1647    fn read_sandbox_deny_blocks_file() {
1648        let dir = temp_dir();
1649        let secret = dir.path().join(".env");
1650        fs::write(&secret, "SECRET=abc").unwrap();
1651
1652        let config = crate::config::FileConfig {
1653            deny_read: vec!["**/.env".to_owned()],
1654            allow_read: vec![],
1655        };
1656        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]).with_read_sandbox(&config);
1657        let params = make_params(&[("path", serde_json::json!(secret.to_str().unwrap()))]);
1658        let result = exec.execute_file_tool("read", &params);
1659        assert!(
1660            matches!(result, Err(ToolError::SandboxViolation { .. })),
1661            "expected SandboxViolation, got: {result:?}"
1662        );
1663    }
1664
1665    #[test]
1666    fn read_sandbox_allow_overrides_deny() {
1667        let dir = temp_dir();
1668        let public = dir.path().join("public.env");
1669        fs::write(&public, "VAR=ok").unwrap();
1670
1671        let config = crate::config::FileConfig {
1672            deny_read: vec!["**/*.env".to_owned()],
1673            allow_read: vec![format!("**/public.env")],
1674        };
1675        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]).with_read_sandbox(&config);
1676        let params = make_params(&[("path", serde_json::json!(public.to_str().unwrap()))]);
1677        let result = exec.execute_file_tool("read", &params);
1678        assert!(
1679            result.is_ok(),
1680            "allow override should permit read: {result:?}"
1681        );
1682    }
1683
1684    #[test]
1685    fn read_sandbox_empty_deny_allows_all() {
1686        let dir = temp_dir();
1687        let file = dir.path().join("data.txt");
1688        fs::write(&file, "data").unwrap();
1689
1690        let config = crate::config::FileConfig::default();
1691        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]).with_read_sandbox(&config);
1692        let params = make_params(&[("path", serde_json::json!(file.to_str().unwrap()))]);
1693        let result = exec.execute_file_tool("read", &params);
1694        assert!(result.is_ok(), "empty deny should allow all: {result:?}");
1695    }
1696
1697    #[test]
1698    fn read_sandbox_grep_skips_denied_files() {
1699        let dir = temp_dir();
1700        let allowed = dir.path().join("allowed.txt");
1701        let denied = dir.path().join(".env");
1702        fs::write(&allowed, "needle").unwrap();
1703        fs::write(&denied, "needle").unwrap();
1704
1705        let config = crate::config::FileConfig {
1706            deny_read: vec!["**/.env".to_owned()],
1707            allow_read: vec![],
1708        };
1709        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]).with_read_sandbox(&config);
1710        let params = make_params(&[
1711            ("pattern", serde_json::json!("needle")),
1712            ("path", serde_json::json!(dir.path().to_str().unwrap())),
1713        ]);
1714        let result = exec.execute_file_tool("grep", &params).unwrap().unwrap();
1715        // Should find match in allowed.txt but not in .env
1716        assert!(
1717            result.summary.contains("allowed.txt"),
1718            "expected match in allowed.txt: {}",
1719            result.summary
1720        );
1721        assert!(
1722            !result.summary.contains(".env"),
1723            "should not match in denied .env: {}",
1724            result.summary
1725        );
1726    }
1727
1728    #[test]
1729    fn find_path_truncates_at_default_limit() {
1730        let dir = temp_dir();
1731        // Create 205 files.
1732        for i in 0..205u32 {
1733            fs::write(dir.path().join(format!("file_{i:04}.txt")), "").unwrap();
1734        }
1735        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1736        let pattern = dir.path().join("*.txt").to_str().unwrap().to_owned();
1737        let params = make_params(&[("pattern", serde_json::json!(pattern))]);
1738        let result = exec
1739            .execute_file_tool("find_path", &params)
1740            .unwrap()
1741            .unwrap();
1742        // Default limit is 200; summary should mention truncation.
1743        assert!(
1744            result.summary.contains("and more results"),
1745            "expected truncation notice: {}",
1746            &result.summary[..100.min(result.summary.len())]
1747        );
1748        // Should contain exactly 200 lines before the truncation notice.
1749        let lines: Vec<&str> = result.summary.lines().collect();
1750        assert_eq!(lines.len(), 201, "expected 200 paths + 1 truncation line");
1751    }
1752
1753    #[test]
1754    fn find_path_respects_max_results() {
1755        let dir = temp_dir();
1756        for i in 0..10u32 {
1757            fs::write(dir.path().join(format!("f_{i}.txt")), "").unwrap();
1758        }
1759        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1760        let pattern = dir.path().join("*.txt").to_str().unwrap().to_owned();
1761        let params = make_params(&[
1762            ("pattern", serde_json::json!(pattern)),
1763            ("max_results", serde_json::json!(5)),
1764        ]);
1765        let result = exec
1766            .execute_file_tool("find_path", &params)
1767            .unwrap()
1768            .unwrap();
1769        assert!(result.summary.contains("and more results"));
1770        let paths: Vec<&str> = result
1771            .summary
1772            .lines()
1773            .filter(|l| {
1774                std::path::Path::new(l)
1775                    .extension()
1776                    .is_some_and(|e| e.eq_ignore_ascii_case("txt"))
1777            })
1778            .collect();
1779        assert_eq!(paths.len(), 5);
1780    }
1781}