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