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