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