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            },
622            ToolDef {
623                id: "write".into(),
624                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(),
625                schema: schemars::schema_for!(WriteParams),
626                invocation: InvocationHint::ToolCall,
627            },
628            ToolDef {
629                id: "edit".into(),
630                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(),
631                schema: schemars::schema_for!(EditParams),
632                invocation: InvocationHint::ToolCall,
633            },
634            ToolDef {
635                id: "find_path".into(),
636                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(),
637                schema: schemars::schema_for!(FindPathParams),
638                invocation: InvocationHint::ToolCall,
639            },
640            ToolDef {
641                id: "grep".into(),
642                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(),
643                schema: schemars::schema_for!(GrepParams),
644                invocation: InvocationHint::ToolCall,
645            },
646            ToolDef {
647                id: "list_directory".into(),
648                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(),
649                schema: schemars::schema_for!(ListDirectoryParams),
650                invocation: InvocationHint::ToolCall,
651            },
652            ToolDef {
653                id: "create_directory".into(),
654                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(),
655                schema: schemars::schema_for!(CreateDirectoryParams),
656                invocation: InvocationHint::ToolCall,
657            },
658            ToolDef {
659                id: "delete_path".into(),
660                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(),
661                schema: schemars::schema_for!(DeletePathParams),
662                invocation: InvocationHint::ToolCall,
663            },
664            ToolDef {
665                id: "move_path".into(),
666                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(),
667                schema: schemars::schema_for!(MovePathParams),
668                invocation: InvocationHint::ToolCall,
669            },
670            ToolDef {
671                id: "copy_path".into(),
672                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(),
673                schema: schemars::schema_for!(CopyPathParams),
674                invocation: InvocationHint::ToolCall,
675            },
676        ]
677    }
678}
679
680/// Lexically normalize a path by collapsing `.` and `..` components without
681/// any filesystem access. This prevents `..` components from bypassing the
682/// sandbox check inside `validate_path`.
683pub(crate) fn normalize_path(path: &Path) -> PathBuf {
684    use std::path::Component;
685    // On Windows, paths may have a drive prefix (e.g. `D:` or `\\?\D:`).
686    // We track it separately so that `RootDir` (the `\` after the drive letter)
687    // does not accidentally clear the prefix from the stack.
688    let mut prefix: Option<std::ffi::OsString> = None;
689    let mut stack: Vec<std::ffi::OsString> = Vec::new();
690    for component in path.components() {
691        match component {
692            Component::CurDir => {}
693            Component::ParentDir => {
694                // Never pop the sentinel "/" root entry.
695                if stack.last().is_some_and(|s| s != "/") {
696                    stack.pop();
697                }
698            }
699            Component::Normal(name) => stack.push(name.to_owned()),
700            Component::RootDir => {
701                if prefix.is_none() {
702                    // Unix absolute path: treat "/" as the root sentinel.
703                    stack.clear();
704                    stack.push(std::ffi::OsString::from("/"));
705                }
706                // On Windows, RootDir follows the drive Prefix and is just the
707                // path separator — the prefix is already recorded, so skip it.
708            }
709            Component::Prefix(p) => {
710                stack.clear();
711                prefix = Some(p.as_os_str().to_owned());
712            }
713        }
714    }
715    if let Some(drive) = prefix {
716        // Windows: reconstruct "DRIVE:\" (absolute) then append normal components.
717        let mut s = drive.to_string_lossy().into_owned();
718        s.push('\\');
719        let mut result = PathBuf::from(s);
720        for part in &stack {
721            result.push(part);
722        }
723        result
724    } else {
725        let mut result = PathBuf::new();
726        for (i, part) in stack.iter().enumerate() {
727            if i == 0 && part == "/" {
728                result.push("/");
729            } else {
730                result.push(part);
731            }
732        }
733        result
734    }
735}
736
737/// Canonicalize a path by walking up to the nearest existing ancestor.
738///
739/// Walks up `path` until an existing ancestor is found, calls `canonicalize()` on it
740/// (which follows symlinks), then re-appends the non-existing suffix. The sandbox check
741/// in `validate_path` uses `starts_with` on the resulting canonical path, so symlinks
742/// that resolve outside `allowed_paths` are correctly rejected.
743fn resolve_via_ancestors(path: &Path) -> PathBuf {
744    let mut existing = path;
745    let mut suffix = PathBuf::new();
746    while !existing.exists() {
747        if let Some(parent) = existing.parent() {
748            if let Some(name) = existing.file_name() {
749                if suffix.as_os_str().is_empty() {
750                    suffix = PathBuf::from(name);
751                } else {
752                    suffix = PathBuf::from(name).join(&suffix);
753                }
754            }
755            existing = parent;
756        } else {
757            break;
758        }
759    }
760    let base = existing.canonicalize().unwrap_or(existing.to_path_buf());
761    if suffix.as_os_str().is_empty() {
762        base
763    } else {
764        base.join(&suffix)
765    }
766}
767
768const IGNORED_DIRS: &[&str] = &[".git", "target", "node_modules", ".hg"];
769
770fn grep_recursive(
771    path: &Path,
772    regex: &regex::Regex,
773    results: &mut Vec<String>,
774    limit: usize,
775    sandbox: &impl Fn(&Path) -> Result<(), ToolError>,
776) -> Result<(), ToolError> {
777    if results.len() >= limit {
778        return Ok(());
779    }
780    if path.is_file() {
781        // Canonicalize before sandbox check to prevent symlink bypass (SEC-01).
782        let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
783        if sandbox(&canonical).is_err() {
784            return Ok(());
785        }
786        if let Ok(content) = std::fs::read_to_string(path) {
787            for (i, line) in content.lines().enumerate() {
788                if regex.is_match(line) {
789                    results.push(format!("{}:{}: {line}", path.display(), i + 1));
790                    if results.len() >= limit {
791                        return Ok(());
792                    }
793                }
794            }
795        }
796    } else if path.is_dir() {
797        let entries = std::fs::read_dir(path)?;
798        for entry in entries.flatten() {
799            let p = entry.path();
800            let name = p.file_name().and_then(|n| n.to_str());
801            if name.is_some_and(|n| n.starts_with('.') || IGNORED_DIRS.contains(&n)) {
802                continue;
803            }
804            grep_recursive(&p, regex, results, limit, sandbox)?;
805        }
806    }
807    Ok(())
808}
809
810fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<(), ToolError> {
811    std::fs::create_dir_all(dst)?;
812    for entry in std::fs::read_dir(src)? {
813        let entry = entry?;
814        // Use symlink_metadata (lstat) so we classify symlinks without following them.
815        // Symlinks are skipped to prevent escaping the sandbox via a symlink pointing
816        // to a path outside allowed_paths.
817        let meta = std::fs::symlink_metadata(entry.path())?;
818        let src_path = entry.path();
819        let dst_path = dst.join(entry.file_name());
820        if meta.is_dir() {
821            copy_dir_recursive(&src_path, &dst_path)?;
822        } else if meta.is_file() {
823            std::fs::copy(&src_path, &dst_path)?;
824        }
825        // Symlinks are intentionally skipped.
826    }
827    Ok(())
828}
829
830#[cfg(test)]
831mod tests {
832    use super::*;
833    use std::fs;
834
835    fn temp_dir() -> tempfile::TempDir {
836        tempfile::tempdir().unwrap()
837    }
838
839    fn make_params(
840        pairs: &[(&str, serde_json::Value)],
841    ) -> serde_json::Map<String, serde_json::Value> {
842        pairs
843            .iter()
844            .map(|(k, v)| ((*k).to_owned(), v.clone()))
845            .collect()
846    }
847
848    #[test]
849    fn read_file() {
850        let dir = temp_dir();
851        let file = dir.path().join("test.txt");
852        fs::write(&file, "line1\nline2\nline3\n").unwrap();
853
854        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
855        let params = make_params(&[("path", serde_json::json!(file.to_str().unwrap()))]);
856        let result = exec.execute_file_tool("read", &params).unwrap().unwrap();
857        assert_eq!(result.tool_name, "read");
858        assert!(result.summary.contains("line1"));
859        assert!(result.summary.contains("line3"));
860    }
861
862    #[test]
863    fn read_with_offset_and_limit() {
864        let dir = temp_dir();
865        let file = dir.path().join("test.txt");
866        fs::write(&file, "a\nb\nc\nd\ne\n").unwrap();
867
868        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
869        let params = make_params(&[
870            ("path", serde_json::json!(file.to_str().unwrap())),
871            ("offset", serde_json::json!(1)),
872            ("limit", serde_json::json!(2)),
873        ]);
874        let result = exec.execute_file_tool("read", &params).unwrap().unwrap();
875        assert!(result.summary.contains('b'));
876        assert!(result.summary.contains('c'));
877        assert!(!result.summary.contains('a'));
878        assert!(!result.summary.contains('d'));
879    }
880
881    #[test]
882    fn write_file() {
883        let dir = temp_dir();
884        let file = dir.path().join("out.txt");
885
886        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
887        let params = make_params(&[
888            ("path", serde_json::json!(file.to_str().unwrap())),
889            ("content", serde_json::json!("hello world")),
890        ]);
891        let result = exec.execute_file_tool("write", &params).unwrap().unwrap();
892        assert!(result.summary.contains("11 bytes"));
893        assert_eq!(fs::read_to_string(&file).unwrap(), "hello world");
894    }
895
896    #[test]
897    fn edit_file() {
898        let dir = temp_dir();
899        let file = dir.path().join("edit.txt");
900        fs::write(&file, "foo bar baz").unwrap();
901
902        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
903        let params = make_params(&[
904            ("path", serde_json::json!(file.to_str().unwrap())),
905            ("old_string", serde_json::json!("bar")),
906            ("new_string", serde_json::json!("qux")),
907        ]);
908        let result = exec.execute_file_tool("edit", &params).unwrap().unwrap();
909        assert!(result.summary.contains("Edited"));
910        assert_eq!(fs::read_to_string(&file).unwrap(), "foo qux baz");
911    }
912
913    #[test]
914    fn edit_not_found() {
915        let dir = temp_dir();
916        let file = dir.path().join("edit.txt");
917        fs::write(&file, "foo bar").unwrap();
918
919        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
920        let params = make_params(&[
921            ("path", serde_json::json!(file.to_str().unwrap())),
922            ("old_string", serde_json::json!("nonexistent")),
923            ("new_string", serde_json::json!("x")),
924        ]);
925        let result = exec.execute_file_tool("edit", &params);
926        assert!(result.is_err());
927    }
928
929    #[test]
930    fn sandbox_violation() {
931        let dir = temp_dir();
932        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
933        let params = make_params(&[("path", serde_json::json!("/etc/passwd"))]);
934        let result = exec.execute_file_tool("read", &params);
935        assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
936    }
937
938    #[test]
939    fn unknown_tool_returns_none() {
940        let exec = FileExecutor::new(vec![]);
941        let params = serde_json::Map::new();
942        let result = exec.execute_file_tool("unknown", &params).unwrap();
943        assert!(result.is_none());
944    }
945
946    #[test]
947    fn find_path_finds_files() {
948        let dir = temp_dir();
949        fs::write(dir.path().join("a.rs"), "").unwrap();
950        fs::write(dir.path().join("b.rs"), "").unwrap();
951
952        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
953        let pattern = format!("{}/*.rs", dir.path().display());
954        let params = make_params(&[("pattern", serde_json::json!(pattern))]);
955        let result = exec
956            .execute_file_tool("find_path", &params)
957            .unwrap()
958            .unwrap();
959        assert!(result.summary.contains("a.rs"));
960        assert!(result.summary.contains("b.rs"));
961    }
962
963    #[test]
964    fn grep_finds_matches() {
965        let dir = temp_dir();
966        fs::write(
967            dir.path().join("test.txt"),
968            "hello world\nfoo bar\nhello again\n",
969        )
970        .unwrap();
971
972        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
973        let params = make_params(&[
974            ("pattern", serde_json::json!("hello")),
975            ("path", serde_json::json!(dir.path().to_str().unwrap())),
976        ]);
977        let result = exec.execute_file_tool("grep", &params).unwrap().unwrap();
978        assert!(result.summary.contains("hello world"));
979        assert!(result.summary.contains("hello again"));
980        assert!(!result.summary.contains("foo bar"));
981    }
982
983    #[test]
984    fn write_sandbox_bypass_nonexistent_path() {
985        let dir = temp_dir();
986        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
987        let params = make_params(&[
988            ("path", serde_json::json!("/tmp/evil/escape.txt")),
989            ("content", serde_json::json!("pwned")),
990        ]);
991        let result = exec.execute_file_tool("write", &params);
992        assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
993        assert!(!Path::new("/tmp/evil/escape.txt").exists());
994    }
995
996    #[test]
997    fn find_path_filters_outside_sandbox() {
998        let sandbox = temp_dir();
999        let outside = temp_dir();
1000        fs::write(outside.path().join("secret.rs"), "secret").unwrap();
1001
1002        let exec = FileExecutor::new(vec![sandbox.path().to_path_buf()]);
1003        let pattern = format!("{}/*.rs", outside.path().display());
1004        let params = make_params(&[("pattern", serde_json::json!(pattern))]);
1005        let result = exec
1006            .execute_file_tool("find_path", &params)
1007            .unwrap()
1008            .unwrap();
1009        assert!(!result.summary.contains("secret.rs"));
1010    }
1011
1012    #[tokio::test]
1013    async fn tool_executor_execute_tool_call_delegates() {
1014        let dir = temp_dir();
1015        let file = dir.path().join("test.txt");
1016        fs::write(&file, "content").unwrap();
1017
1018        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1019        let call = ToolCall {
1020            tool_id: ToolName::new("read"),
1021            params: make_params(&[("path", serde_json::json!(file.to_str().unwrap()))]),
1022            caller_id: None,
1023        };
1024        let result = exec.execute_tool_call(&call).await.unwrap().unwrap();
1025        assert_eq!(result.tool_name, "read");
1026        assert!(result.summary.contains("content"));
1027    }
1028
1029    #[test]
1030    fn tool_executor_tool_definitions_lists_all() {
1031        let exec = FileExecutor::new(vec![]);
1032        let defs = exec.tool_definitions();
1033        let ids: Vec<&str> = defs.iter().map(|d| d.id.as_ref()).collect();
1034        assert!(ids.contains(&"read"));
1035        assert!(ids.contains(&"write"));
1036        assert!(ids.contains(&"edit"));
1037        assert!(ids.contains(&"find_path"));
1038        assert!(ids.contains(&"grep"));
1039        assert!(ids.contains(&"list_directory"));
1040        assert!(ids.contains(&"create_directory"));
1041        assert!(ids.contains(&"delete_path"));
1042        assert!(ids.contains(&"move_path"));
1043        assert!(ids.contains(&"copy_path"));
1044        assert_eq!(defs.len(), 10);
1045    }
1046
1047    #[test]
1048    fn grep_relative_path_validated() {
1049        let sandbox = temp_dir();
1050        let exec = FileExecutor::new(vec![sandbox.path().to_path_buf()]);
1051        let params = make_params(&[
1052            ("pattern", serde_json::json!("password")),
1053            ("path", serde_json::json!("../../etc")),
1054        ]);
1055        let result = exec.execute_file_tool("grep", &params);
1056        assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
1057    }
1058
1059    #[test]
1060    fn tool_definitions_returns_ten_tools() {
1061        let exec = FileExecutor::new(vec![]);
1062        let defs = exec.tool_definitions();
1063        assert_eq!(defs.len(), 10);
1064        let ids: Vec<&str> = defs.iter().map(|d| d.id.as_ref()).collect();
1065        assert_eq!(
1066            ids,
1067            vec![
1068                "read",
1069                "write",
1070                "edit",
1071                "find_path",
1072                "grep",
1073                "list_directory",
1074                "create_directory",
1075                "delete_path",
1076                "move_path",
1077                "copy_path",
1078            ]
1079        );
1080    }
1081
1082    #[test]
1083    fn tool_definitions_all_use_tool_call() {
1084        let exec = FileExecutor::new(vec![]);
1085        for def in exec.tool_definitions() {
1086            assert_eq!(def.invocation, InvocationHint::ToolCall);
1087        }
1088    }
1089
1090    #[test]
1091    fn tool_definitions_read_schema_has_params() {
1092        let exec = FileExecutor::new(vec![]);
1093        let defs = exec.tool_definitions();
1094        let read = defs.iter().find(|d| d.id.as_ref() == "read").unwrap();
1095        let obj = read.schema.as_object().unwrap();
1096        let props = obj["properties"].as_object().unwrap();
1097        assert!(props.contains_key("path"));
1098        assert!(props.contains_key("offset"));
1099        assert!(props.contains_key("limit"));
1100    }
1101
1102    #[test]
1103    fn missing_required_path_returns_invalid_params() {
1104        let dir = temp_dir();
1105        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1106        let params = serde_json::Map::new();
1107        let result = exec.execute_file_tool("read", &params);
1108        assert!(matches!(result, Err(ToolError::InvalidParams { .. })));
1109    }
1110
1111    // --- list_directory tests ---
1112
1113    #[test]
1114    fn list_directory_returns_entries() {
1115        let dir = temp_dir();
1116        fs::write(dir.path().join("file.txt"), "").unwrap();
1117        fs::create_dir(dir.path().join("subdir")).unwrap();
1118
1119        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1120        let params = make_params(&[("path", serde_json::json!(dir.path().to_str().unwrap()))]);
1121        let result = exec
1122            .execute_file_tool("list_directory", &params)
1123            .unwrap()
1124            .unwrap();
1125        assert!(result.summary.contains("[dir]  subdir"));
1126        assert!(result.summary.contains("[file] file.txt"));
1127        // dirs listed before files
1128        let dir_pos = result.summary.find("[dir]").unwrap();
1129        let file_pos = result.summary.find("[file]").unwrap();
1130        assert!(dir_pos < file_pos);
1131    }
1132
1133    #[test]
1134    fn list_directory_empty_dir() {
1135        let dir = temp_dir();
1136        let subdir = dir.path().join("empty");
1137        fs::create_dir(&subdir).unwrap();
1138
1139        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1140        let params = make_params(&[("path", serde_json::json!(subdir.to_str().unwrap()))]);
1141        let result = exec
1142            .execute_file_tool("list_directory", &params)
1143            .unwrap()
1144            .unwrap();
1145        assert!(result.summary.contains("Empty directory"));
1146    }
1147
1148    #[test]
1149    fn list_directory_sandbox_violation() {
1150        let dir = temp_dir();
1151        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1152        let params = make_params(&[("path", serde_json::json!("/etc"))]);
1153        let result = exec.execute_file_tool("list_directory", &params);
1154        assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
1155    }
1156
1157    #[test]
1158    fn list_directory_nonexistent_returns_error() {
1159        let dir = temp_dir();
1160        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1161        let missing = dir.path().join("nonexistent");
1162        let params = make_params(&[("path", serde_json::json!(missing.to_str().unwrap()))]);
1163        let result = exec.execute_file_tool("list_directory", &params);
1164        assert!(result.is_err());
1165    }
1166
1167    #[test]
1168    fn list_directory_on_file_returns_error() {
1169        let dir = temp_dir();
1170        let file = dir.path().join("file.txt");
1171        fs::write(&file, "content").unwrap();
1172
1173        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1174        let params = make_params(&[("path", serde_json::json!(file.to_str().unwrap()))]);
1175        let result = exec.execute_file_tool("list_directory", &params);
1176        assert!(result.is_err());
1177    }
1178
1179    // --- create_directory tests ---
1180
1181    #[test]
1182    fn create_directory_creates_nested() {
1183        let dir = temp_dir();
1184        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1185        let nested = dir.path().join("a/b/c");
1186        let params = make_params(&[("path", serde_json::json!(nested.to_str().unwrap()))]);
1187        let result = exec
1188            .execute_file_tool("create_directory", &params)
1189            .unwrap()
1190            .unwrap();
1191        assert!(result.summary.contains("Created"));
1192        assert!(nested.is_dir());
1193    }
1194
1195    #[test]
1196    fn create_directory_sandbox_violation() {
1197        let dir = temp_dir();
1198        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1199        let params = make_params(&[("path", serde_json::json!("/tmp/evil_dir"))]);
1200        let result = exec.execute_file_tool("create_directory", &params);
1201        assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
1202    }
1203
1204    // --- delete_path tests ---
1205
1206    #[test]
1207    fn delete_path_file() {
1208        let dir = temp_dir();
1209        let file = dir.path().join("del.txt");
1210        fs::write(&file, "bye").unwrap();
1211
1212        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1213        let params = make_params(&[("path", serde_json::json!(file.to_str().unwrap()))]);
1214        exec.execute_file_tool("delete_path", &params)
1215            .unwrap()
1216            .unwrap();
1217        assert!(!file.exists());
1218    }
1219
1220    #[test]
1221    fn delete_path_empty_directory() {
1222        let dir = temp_dir();
1223        let subdir = dir.path().join("empty_sub");
1224        fs::create_dir(&subdir).unwrap();
1225
1226        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1227        let params = make_params(&[("path", serde_json::json!(subdir.to_str().unwrap()))]);
1228        exec.execute_file_tool("delete_path", &params)
1229            .unwrap()
1230            .unwrap();
1231        assert!(!subdir.exists());
1232    }
1233
1234    #[test]
1235    fn delete_path_non_empty_dir_without_recursive_fails() {
1236        let dir = temp_dir();
1237        let subdir = dir.path().join("nonempty");
1238        fs::create_dir(&subdir).unwrap();
1239        fs::write(subdir.join("file.txt"), "x").unwrap();
1240
1241        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1242        let params = make_params(&[("path", serde_json::json!(subdir.to_str().unwrap()))]);
1243        let result = exec.execute_file_tool("delete_path", &params);
1244        assert!(result.is_err());
1245    }
1246
1247    #[test]
1248    fn delete_path_recursive() {
1249        let dir = temp_dir();
1250        let subdir = dir.path().join("recurse");
1251        fs::create_dir(&subdir).unwrap();
1252        fs::write(subdir.join("f.txt"), "x").unwrap();
1253
1254        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1255        let params = make_params(&[
1256            ("path", serde_json::json!(subdir.to_str().unwrap())),
1257            ("recursive", serde_json::json!(true)),
1258        ]);
1259        exec.execute_file_tool("delete_path", &params)
1260            .unwrap()
1261            .unwrap();
1262        assert!(!subdir.exists());
1263    }
1264
1265    #[test]
1266    fn delete_path_sandbox_violation() {
1267        let dir = temp_dir();
1268        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1269        let params = make_params(&[("path", serde_json::json!("/etc/hosts"))]);
1270        let result = exec.execute_file_tool("delete_path", &params);
1271        assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
1272    }
1273
1274    #[test]
1275    fn delete_path_refuses_sandbox_root() {
1276        let dir = temp_dir();
1277        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1278        let params = make_params(&[
1279            ("path", serde_json::json!(dir.path().to_str().unwrap())),
1280            ("recursive", serde_json::json!(true)),
1281        ]);
1282        let result = exec.execute_file_tool("delete_path", &params);
1283        assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
1284    }
1285
1286    // --- move_path tests ---
1287
1288    #[test]
1289    fn move_path_renames_file() {
1290        let dir = temp_dir();
1291        let src = dir.path().join("src.txt");
1292        let dst = dir.path().join("dst.txt");
1293        fs::write(&src, "data").unwrap();
1294
1295        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1296        let params = make_params(&[
1297            ("source", serde_json::json!(src.to_str().unwrap())),
1298            ("destination", serde_json::json!(dst.to_str().unwrap())),
1299        ]);
1300        exec.execute_file_tool("move_path", &params)
1301            .unwrap()
1302            .unwrap();
1303        assert!(!src.exists());
1304        assert_eq!(fs::read_to_string(&dst).unwrap(), "data");
1305    }
1306
1307    #[test]
1308    fn move_path_cross_sandbox_denied() {
1309        let sandbox = temp_dir();
1310        let outside = temp_dir();
1311        let src = sandbox.path().join("src.txt");
1312        fs::write(&src, "x").unwrap();
1313
1314        let exec = FileExecutor::new(vec![sandbox.path().to_path_buf()]);
1315        let dst = outside.path().join("dst.txt");
1316        let params = make_params(&[
1317            ("source", serde_json::json!(src.to_str().unwrap())),
1318            ("destination", serde_json::json!(dst.to_str().unwrap())),
1319        ]);
1320        let result = exec.execute_file_tool("move_path", &params);
1321        assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
1322    }
1323
1324    // --- copy_path tests ---
1325
1326    #[test]
1327    fn copy_path_file() {
1328        let dir = temp_dir();
1329        let src = dir.path().join("src.txt");
1330        let dst = dir.path().join("dst.txt");
1331        fs::write(&src, "hello").unwrap();
1332
1333        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1334        let params = make_params(&[
1335            ("source", serde_json::json!(src.to_str().unwrap())),
1336            ("destination", serde_json::json!(dst.to_str().unwrap())),
1337        ]);
1338        exec.execute_file_tool("copy_path", &params)
1339            .unwrap()
1340            .unwrap();
1341        assert_eq!(fs::read_to_string(&src).unwrap(), "hello");
1342        assert_eq!(fs::read_to_string(&dst).unwrap(), "hello");
1343    }
1344
1345    #[test]
1346    fn copy_path_directory_recursive() {
1347        let dir = temp_dir();
1348        let src_dir = dir.path().join("src_dir");
1349        fs::create_dir(&src_dir).unwrap();
1350        fs::write(src_dir.join("a.txt"), "aaa").unwrap();
1351
1352        let dst_dir = dir.path().join("dst_dir");
1353
1354        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1355        let params = make_params(&[
1356            ("source", serde_json::json!(src_dir.to_str().unwrap())),
1357            ("destination", serde_json::json!(dst_dir.to_str().unwrap())),
1358        ]);
1359        exec.execute_file_tool("copy_path", &params)
1360            .unwrap()
1361            .unwrap();
1362        assert_eq!(fs::read_to_string(dst_dir.join("a.txt")).unwrap(), "aaa");
1363    }
1364
1365    #[test]
1366    fn copy_path_sandbox_violation() {
1367        let sandbox = temp_dir();
1368        let outside = temp_dir();
1369        let src = sandbox.path().join("src.txt");
1370        fs::write(&src, "x").unwrap();
1371
1372        let exec = FileExecutor::new(vec![sandbox.path().to_path_buf()]);
1373        let dst = outside.path().join("dst.txt");
1374        let params = make_params(&[
1375            ("source", serde_json::json!(src.to_str().unwrap())),
1376            ("destination", serde_json::json!(dst.to_str().unwrap())),
1377        ]);
1378        let result = exec.execute_file_tool("copy_path", &params);
1379        assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
1380    }
1381
1382    // CR-11: invalid glob pattern returns error
1383    #[test]
1384    fn find_path_invalid_pattern_returns_error() {
1385        let dir = temp_dir();
1386        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1387        let params = make_params(&[("pattern", serde_json::json!("[invalid"))]);
1388        let result = exec.execute_file_tool("find_path", &params);
1389        assert!(result.is_err());
1390    }
1391
1392    // CR-12: create_directory is idempotent on existing dir
1393    #[test]
1394    fn create_directory_idempotent() {
1395        let dir = temp_dir();
1396        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1397        let target = dir.path().join("exists");
1398        fs::create_dir(&target).unwrap();
1399
1400        let params = make_params(&[("path", serde_json::json!(target.to_str().unwrap()))]);
1401        let result = exec.execute_file_tool("create_directory", &params);
1402        assert!(result.is_ok());
1403        assert!(target.is_dir());
1404    }
1405
1406    // CR-13: move_path source sandbox violation
1407    #[test]
1408    fn move_path_source_sandbox_violation() {
1409        let sandbox = temp_dir();
1410        let outside = temp_dir();
1411        let src = outside.path().join("src.txt");
1412        fs::write(&src, "x").unwrap();
1413
1414        let exec = FileExecutor::new(vec![sandbox.path().to_path_buf()]);
1415        let dst = sandbox.path().join("dst.txt");
1416        let params = make_params(&[
1417            ("source", serde_json::json!(src.to_str().unwrap())),
1418            ("destination", serde_json::json!(dst.to_str().unwrap())),
1419        ]);
1420        let result = exec.execute_file_tool("move_path", &params);
1421        assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
1422    }
1423
1424    // CR-13: copy_path source sandbox violation
1425    #[test]
1426    fn copy_path_source_sandbox_violation() {
1427        let sandbox = temp_dir();
1428        let outside = temp_dir();
1429        let src = outside.path().join("src.txt");
1430        fs::write(&src, "x").unwrap();
1431
1432        let exec = FileExecutor::new(vec![sandbox.path().to_path_buf()]);
1433        let dst = sandbox.path().join("dst.txt");
1434        let params = make_params(&[
1435            ("source", serde_json::json!(src.to_str().unwrap())),
1436            ("destination", serde_json::json!(dst.to_str().unwrap())),
1437        ]);
1438        let result = exec.execute_file_tool("copy_path", &params);
1439        assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
1440    }
1441
1442    // CR-01: copy_dir_recursive skips symlinks
1443    #[cfg(unix)]
1444    #[test]
1445    fn copy_dir_skips_symlinks() {
1446        let dir = temp_dir();
1447        let src_dir = dir.path().join("src");
1448        fs::create_dir(&src_dir).unwrap();
1449        fs::write(src_dir.join("real.txt"), "real").unwrap();
1450
1451        // Create a symlink inside src pointing outside sandbox
1452        let outside = temp_dir();
1453        std::os::unix::fs::symlink(outside.path(), src_dir.join("link")).unwrap();
1454
1455        let dst_dir = dir.path().join("dst");
1456        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1457        let params = make_params(&[
1458            ("source", serde_json::json!(src_dir.to_str().unwrap())),
1459            ("destination", serde_json::json!(dst_dir.to_str().unwrap())),
1460        ]);
1461        exec.execute_file_tool("copy_path", &params)
1462            .unwrap()
1463            .unwrap();
1464        // Real file copied
1465        assert_eq!(
1466            fs::read_to_string(dst_dir.join("real.txt")).unwrap(),
1467            "real"
1468        );
1469        // Symlink not copied
1470        assert!(!dst_dir.join("link").exists());
1471    }
1472
1473    // CR-04: list_directory detects symlinks
1474    #[cfg(unix)]
1475    #[test]
1476    fn list_directory_shows_symlinks() {
1477        let dir = temp_dir();
1478        let target = dir.path().join("target.txt");
1479        fs::write(&target, "x").unwrap();
1480        std::os::unix::fs::symlink(&target, dir.path().join("link")).unwrap();
1481
1482        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1483        let params = make_params(&[("path", serde_json::json!(dir.path().to_str().unwrap()))]);
1484        let result = exec
1485            .execute_file_tool("list_directory", &params)
1486            .unwrap()
1487            .unwrap();
1488        assert!(result.summary.contains("[symlink] link"));
1489        assert!(result.summary.contains("[file] target.txt"));
1490    }
1491
1492    #[test]
1493    fn tilde_path_is_expanded() {
1494        let exec = FileExecutor::new(vec![PathBuf::from("~/nonexistent_subdir_for_test")]);
1495        assert!(
1496            !exec.allowed_paths[0].to_string_lossy().starts_with('~'),
1497            "tilde was not expanded: {:?}",
1498            exec.allowed_paths[0]
1499        );
1500    }
1501
1502    #[test]
1503    fn absolute_path_unchanged() {
1504        let exec = FileExecutor::new(vec![PathBuf::from("/tmp")]);
1505        // On macOS /tmp is a symlink to /private/tmp; canonicalize resolves it.
1506        // The invariant is that the result is absolute and tilde-free.
1507        let p = exec.allowed_paths[0].to_string_lossy();
1508        assert!(
1509            p.starts_with('/'),
1510            "expected absolute path, got: {:?}",
1511            exec.allowed_paths[0]
1512        );
1513        assert!(
1514            !p.starts_with('~'),
1515            "tilde must not appear in result: {:?}",
1516            exec.allowed_paths[0]
1517        );
1518    }
1519
1520    #[test]
1521    fn tilde_only_expands_to_home() {
1522        let exec = FileExecutor::new(vec![PathBuf::from("~")]);
1523        assert!(
1524            !exec.allowed_paths[0].to_string_lossy().starts_with('~'),
1525            "bare tilde was not expanded: {:?}",
1526            exec.allowed_paths[0]
1527        );
1528    }
1529
1530    #[test]
1531    fn empty_allowed_paths_uses_cwd() {
1532        let exec = FileExecutor::new(vec![]);
1533        assert!(
1534            !exec.allowed_paths.is_empty(),
1535            "expected cwd fallback, got empty allowed_paths"
1536        );
1537    }
1538
1539    // --- normalize_path tests ---
1540
1541    #[test]
1542    fn normalize_path_normal_path() {
1543        assert_eq!(
1544            normalize_path(Path::new("/tmp/sandbox/file.txt")),
1545            PathBuf::from("/tmp/sandbox/file.txt")
1546        );
1547    }
1548
1549    #[test]
1550    fn normalize_path_collapses_dot() {
1551        assert_eq!(
1552            normalize_path(Path::new("/tmp/sandbox/./file.txt")),
1553            PathBuf::from("/tmp/sandbox/file.txt")
1554        );
1555    }
1556
1557    #[test]
1558    fn normalize_path_collapses_dotdot() {
1559        assert_eq!(
1560            normalize_path(Path::new("/tmp/sandbox/nonexistent/../../etc/passwd")),
1561            PathBuf::from("/tmp/etc/passwd")
1562        );
1563    }
1564
1565    #[test]
1566    fn normalize_path_nested_dotdot() {
1567        assert_eq!(
1568            normalize_path(Path::new("/tmp/sandbox/a/b/../../../etc/passwd")),
1569            PathBuf::from("/tmp/etc/passwd")
1570        );
1571    }
1572
1573    #[test]
1574    fn normalize_path_at_sandbox_boundary() {
1575        assert_eq!(
1576            normalize_path(Path::new("/tmp/sandbox")),
1577            PathBuf::from("/tmp/sandbox")
1578        );
1579    }
1580
1581    // --- validate_path dotdot bypass tests ---
1582
1583    #[test]
1584    fn validate_path_dotdot_bypass_nonexistent_blocked() {
1585        let dir = temp_dir();
1586        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1587        // /sandbox/nonexistent/../../etc/passwd normalizes to /etc/passwd — must be blocked
1588        let escape = format!("{}/nonexistent/../../etc/passwd", dir.path().display());
1589        let params = make_params(&[("path", serde_json::json!(escape))]);
1590        let result = exec.execute_file_tool("read", &params);
1591        assert!(
1592            matches!(result, Err(ToolError::SandboxViolation { .. })),
1593            "expected SandboxViolation for dotdot bypass, got {result:?}"
1594        );
1595    }
1596
1597    #[test]
1598    fn validate_path_dotdot_nested_bypass_blocked() {
1599        let dir = temp_dir();
1600        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1601        let escape = format!("{}/a/b/../../../etc/shadow", dir.path().display());
1602        let params = make_params(&[("path", serde_json::json!(escape))]);
1603        let result = exec.execute_file_tool("read", &params);
1604        assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
1605    }
1606
1607    #[test]
1608    fn validate_path_inside_sandbox_passes() {
1609        let dir = temp_dir();
1610        let file = dir.path().join("allowed.txt");
1611        fs::write(&file, "ok").unwrap();
1612        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1613        let params = make_params(&[("path", serde_json::json!(file.to_str().unwrap()))]);
1614        let result = exec.execute_file_tool("read", &params);
1615        assert!(result.is_ok());
1616    }
1617
1618    #[test]
1619    fn validate_path_dot_components_inside_sandbox_passes() {
1620        let dir = temp_dir();
1621        let file = dir.path().join("sub/file.txt");
1622        fs::create_dir_all(dir.path().join("sub")).unwrap();
1623        fs::write(&file, "ok").unwrap();
1624        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1625        let dotpath = format!("{}/sub/./file.txt", dir.path().display());
1626        let params = make_params(&[("path", serde_json::json!(dotpath))]);
1627        let result = exec.execute_file_tool("read", &params);
1628        assert!(result.is_ok());
1629    }
1630
1631    // --- #2489: per-path read allow/deny sandbox tests ---
1632
1633    #[test]
1634    fn read_sandbox_deny_blocks_file() {
1635        let dir = temp_dir();
1636        let secret = dir.path().join(".env");
1637        fs::write(&secret, "SECRET=abc").unwrap();
1638
1639        let config = crate::config::FileConfig {
1640            deny_read: vec!["**/.env".to_owned()],
1641            allow_read: vec![],
1642        };
1643        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]).with_read_sandbox(&config);
1644        let params = make_params(&[("path", serde_json::json!(secret.to_str().unwrap()))]);
1645        let result = exec.execute_file_tool("read", &params);
1646        assert!(
1647            matches!(result, Err(ToolError::SandboxViolation { .. })),
1648            "expected SandboxViolation, got: {result:?}"
1649        );
1650    }
1651
1652    #[test]
1653    fn read_sandbox_allow_overrides_deny() {
1654        let dir = temp_dir();
1655        let public = dir.path().join("public.env");
1656        fs::write(&public, "VAR=ok").unwrap();
1657
1658        let config = crate::config::FileConfig {
1659            deny_read: vec!["**/*.env".to_owned()],
1660            allow_read: vec![format!("**/public.env")],
1661        };
1662        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]).with_read_sandbox(&config);
1663        let params = make_params(&[("path", serde_json::json!(public.to_str().unwrap()))]);
1664        let result = exec.execute_file_tool("read", &params);
1665        assert!(
1666            result.is_ok(),
1667            "allow override should permit read: {result:?}"
1668        );
1669    }
1670
1671    #[test]
1672    fn read_sandbox_empty_deny_allows_all() {
1673        let dir = temp_dir();
1674        let file = dir.path().join("data.txt");
1675        fs::write(&file, "data").unwrap();
1676
1677        let config = crate::config::FileConfig::default();
1678        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]).with_read_sandbox(&config);
1679        let params = make_params(&[("path", serde_json::json!(file.to_str().unwrap()))]);
1680        let result = exec.execute_file_tool("read", &params);
1681        assert!(result.is_ok(), "empty deny should allow all: {result:?}");
1682    }
1683
1684    #[test]
1685    fn read_sandbox_grep_skips_denied_files() {
1686        let dir = temp_dir();
1687        let allowed = dir.path().join("allowed.txt");
1688        let denied = dir.path().join(".env");
1689        fs::write(&allowed, "needle").unwrap();
1690        fs::write(&denied, "needle").unwrap();
1691
1692        let config = crate::config::FileConfig {
1693            deny_read: vec!["**/.env".to_owned()],
1694            allow_read: vec![],
1695        };
1696        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]).with_read_sandbox(&config);
1697        let params = make_params(&[
1698            ("pattern", serde_json::json!("needle")),
1699            ("path", serde_json::json!(dir.path().to_str().unwrap())),
1700        ]);
1701        let result = exec.execute_file_tool("grep", &params).unwrap().unwrap();
1702        // Should find match in allowed.txt but not in .env
1703        assert!(
1704            result.summary.contains("allowed.txt"),
1705            "expected match in allowed.txt: {}",
1706            result.summary
1707        );
1708        assert!(
1709            !result.summary.contains(".env"),
1710            "should not match in denied .env: {}",
1711            result.summary
1712        );
1713    }
1714
1715    #[test]
1716    fn find_path_truncates_at_default_limit() {
1717        let dir = temp_dir();
1718        // Create 205 files.
1719        for i in 0..205u32 {
1720            fs::write(dir.path().join(format!("file_{i:04}.txt")), "").unwrap();
1721        }
1722        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1723        let pattern = dir.path().join("*.txt").to_str().unwrap().to_owned();
1724        let params = make_params(&[("pattern", serde_json::json!(pattern))]);
1725        let result = exec
1726            .execute_file_tool("find_path", &params)
1727            .unwrap()
1728            .unwrap();
1729        // Default limit is 200; summary should mention truncation.
1730        assert!(
1731            result.summary.contains("and more results"),
1732            "expected truncation notice: {}",
1733            &result.summary[..100.min(result.summary.len())]
1734        );
1735        // Should contain exactly 200 lines before the truncation notice.
1736        let lines: Vec<&str> = result.summary.lines().collect();
1737        assert_eq!(lines.len(), 201, "expected 200 paths + 1 truncation line");
1738    }
1739
1740    #[test]
1741    fn find_path_respects_max_results() {
1742        let dir = temp_dir();
1743        for i in 0..10u32 {
1744            fs::write(dir.path().join(format!("f_{i}.txt")), "").unwrap();
1745        }
1746        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1747        let pattern = dir.path().join("*.txt").to_str().unwrap().to_owned();
1748        let params = make_params(&[
1749            ("pattern", serde_json::json!(pattern)),
1750            ("max_results", serde_json::json!(5)),
1751        ]);
1752        let result = exec
1753            .execute_file_tool("find_path", &params)
1754            .unwrap()
1755            .unwrap();
1756        assert!(result.summary.contains("and more results"));
1757        let paths: Vec<&str> = result
1758            .summary
1759            .lines()
1760            .filter(|l| {
1761                std::path::Path::new(l)
1762                    .extension()
1763                    .is_some_and(|e| e.eq_ignore_ascii_case("txt"))
1764            })
1765            .collect();
1766        assert_eq!(paths.len(), 5);
1767    }
1768}