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