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