Skip to main content

zeph_tools/
file.rs

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