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`.
660pub(crate) fn 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            caller_id: None,
1000        };
1001        let result = exec.execute_tool_call(&call).await.unwrap().unwrap();
1002        assert_eq!(result.tool_name, "read");
1003        assert!(result.summary.contains("content"));
1004    }
1005
1006    #[test]
1007    fn tool_executor_tool_definitions_lists_all() {
1008        let exec = FileExecutor::new(vec![]);
1009        let defs = exec.tool_definitions();
1010        let ids: Vec<&str> = defs.iter().map(|d| d.id.as_ref()).collect();
1011        assert!(ids.contains(&"read"));
1012        assert!(ids.contains(&"write"));
1013        assert!(ids.contains(&"edit"));
1014        assert!(ids.contains(&"find_path"));
1015        assert!(ids.contains(&"grep"));
1016        assert!(ids.contains(&"list_directory"));
1017        assert!(ids.contains(&"create_directory"));
1018        assert!(ids.contains(&"delete_path"));
1019        assert!(ids.contains(&"move_path"));
1020        assert!(ids.contains(&"copy_path"));
1021        assert_eq!(defs.len(), 10);
1022    }
1023
1024    #[test]
1025    fn grep_relative_path_validated() {
1026        let sandbox = temp_dir();
1027        let exec = FileExecutor::new(vec![sandbox.path().to_path_buf()]);
1028        let params = make_params(&[
1029            ("pattern", serde_json::json!("password")),
1030            ("path", serde_json::json!("../../etc")),
1031        ]);
1032        let result = exec.execute_file_tool("grep", &params);
1033        assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
1034    }
1035
1036    #[test]
1037    fn tool_definitions_returns_ten_tools() {
1038        let exec = FileExecutor::new(vec![]);
1039        let defs = exec.tool_definitions();
1040        assert_eq!(defs.len(), 10);
1041        let ids: Vec<&str> = defs.iter().map(|d| d.id.as_ref()).collect();
1042        assert_eq!(
1043            ids,
1044            vec![
1045                "read",
1046                "write",
1047                "edit",
1048                "find_path",
1049                "grep",
1050                "list_directory",
1051                "create_directory",
1052                "delete_path",
1053                "move_path",
1054                "copy_path",
1055            ]
1056        );
1057    }
1058
1059    #[test]
1060    fn tool_definitions_all_use_tool_call() {
1061        let exec = FileExecutor::new(vec![]);
1062        for def in exec.tool_definitions() {
1063            assert_eq!(def.invocation, InvocationHint::ToolCall);
1064        }
1065    }
1066
1067    #[test]
1068    fn tool_definitions_read_schema_has_params() {
1069        let exec = FileExecutor::new(vec![]);
1070        let defs = exec.tool_definitions();
1071        let read = defs.iter().find(|d| d.id.as_ref() == "read").unwrap();
1072        let obj = read.schema.as_object().unwrap();
1073        let props = obj["properties"].as_object().unwrap();
1074        assert!(props.contains_key("path"));
1075        assert!(props.contains_key("offset"));
1076        assert!(props.contains_key("limit"));
1077    }
1078
1079    #[test]
1080    fn missing_required_path_returns_invalid_params() {
1081        let dir = temp_dir();
1082        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1083        let params = serde_json::Map::new();
1084        let result = exec.execute_file_tool("read", &params);
1085        assert!(matches!(result, Err(ToolError::InvalidParams { .. })));
1086    }
1087
1088    // --- list_directory tests ---
1089
1090    #[test]
1091    fn list_directory_returns_entries() {
1092        let dir = temp_dir();
1093        fs::write(dir.path().join("file.txt"), "").unwrap();
1094        fs::create_dir(dir.path().join("subdir")).unwrap();
1095
1096        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1097        let params = make_params(&[("path", serde_json::json!(dir.path().to_str().unwrap()))]);
1098        let result = exec
1099            .execute_file_tool("list_directory", &params)
1100            .unwrap()
1101            .unwrap();
1102        assert!(result.summary.contains("[dir]  subdir"));
1103        assert!(result.summary.contains("[file] file.txt"));
1104        // dirs listed before files
1105        let dir_pos = result.summary.find("[dir]").unwrap();
1106        let file_pos = result.summary.find("[file]").unwrap();
1107        assert!(dir_pos < file_pos);
1108    }
1109
1110    #[test]
1111    fn list_directory_empty_dir() {
1112        let dir = temp_dir();
1113        let subdir = dir.path().join("empty");
1114        fs::create_dir(&subdir).unwrap();
1115
1116        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1117        let params = make_params(&[("path", serde_json::json!(subdir.to_str().unwrap()))]);
1118        let result = exec
1119            .execute_file_tool("list_directory", &params)
1120            .unwrap()
1121            .unwrap();
1122        assert!(result.summary.contains("Empty directory"));
1123    }
1124
1125    #[test]
1126    fn list_directory_sandbox_violation() {
1127        let dir = temp_dir();
1128        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1129        let params = make_params(&[("path", serde_json::json!("/etc"))]);
1130        let result = exec.execute_file_tool("list_directory", &params);
1131        assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
1132    }
1133
1134    #[test]
1135    fn list_directory_nonexistent_returns_error() {
1136        let dir = temp_dir();
1137        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1138        let missing = dir.path().join("nonexistent");
1139        let params = make_params(&[("path", serde_json::json!(missing.to_str().unwrap()))]);
1140        let result = exec.execute_file_tool("list_directory", &params);
1141        assert!(result.is_err());
1142    }
1143
1144    #[test]
1145    fn list_directory_on_file_returns_error() {
1146        let dir = temp_dir();
1147        let file = dir.path().join("file.txt");
1148        fs::write(&file, "content").unwrap();
1149
1150        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1151        let params = make_params(&[("path", serde_json::json!(file.to_str().unwrap()))]);
1152        let result = exec.execute_file_tool("list_directory", &params);
1153        assert!(result.is_err());
1154    }
1155
1156    // --- create_directory tests ---
1157
1158    #[test]
1159    fn create_directory_creates_nested() {
1160        let dir = temp_dir();
1161        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1162        let nested = dir.path().join("a/b/c");
1163        let params = make_params(&[("path", serde_json::json!(nested.to_str().unwrap()))]);
1164        let result = exec
1165            .execute_file_tool("create_directory", &params)
1166            .unwrap()
1167            .unwrap();
1168        assert!(result.summary.contains("Created"));
1169        assert!(nested.is_dir());
1170    }
1171
1172    #[test]
1173    fn create_directory_sandbox_violation() {
1174        let dir = temp_dir();
1175        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1176        let params = make_params(&[("path", serde_json::json!("/tmp/evil_dir"))]);
1177        let result = exec.execute_file_tool("create_directory", &params);
1178        assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
1179    }
1180
1181    // --- delete_path tests ---
1182
1183    #[test]
1184    fn delete_path_file() {
1185        let dir = temp_dir();
1186        let file = dir.path().join("del.txt");
1187        fs::write(&file, "bye").unwrap();
1188
1189        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1190        let params = make_params(&[("path", serde_json::json!(file.to_str().unwrap()))]);
1191        exec.execute_file_tool("delete_path", &params)
1192            .unwrap()
1193            .unwrap();
1194        assert!(!file.exists());
1195    }
1196
1197    #[test]
1198    fn delete_path_empty_directory() {
1199        let dir = temp_dir();
1200        let subdir = dir.path().join("empty_sub");
1201        fs::create_dir(&subdir).unwrap();
1202
1203        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1204        let params = make_params(&[("path", serde_json::json!(subdir.to_str().unwrap()))]);
1205        exec.execute_file_tool("delete_path", &params)
1206            .unwrap()
1207            .unwrap();
1208        assert!(!subdir.exists());
1209    }
1210
1211    #[test]
1212    fn delete_path_non_empty_dir_without_recursive_fails() {
1213        let dir = temp_dir();
1214        let subdir = dir.path().join("nonempty");
1215        fs::create_dir(&subdir).unwrap();
1216        fs::write(subdir.join("file.txt"), "x").unwrap();
1217
1218        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1219        let params = make_params(&[("path", serde_json::json!(subdir.to_str().unwrap()))]);
1220        let result = exec.execute_file_tool("delete_path", &params);
1221        assert!(result.is_err());
1222    }
1223
1224    #[test]
1225    fn delete_path_recursive() {
1226        let dir = temp_dir();
1227        let subdir = dir.path().join("recurse");
1228        fs::create_dir(&subdir).unwrap();
1229        fs::write(subdir.join("f.txt"), "x").unwrap();
1230
1231        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1232        let params = make_params(&[
1233            ("path", serde_json::json!(subdir.to_str().unwrap())),
1234            ("recursive", serde_json::json!(true)),
1235        ]);
1236        exec.execute_file_tool("delete_path", &params)
1237            .unwrap()
1238            .unwrap();
1239        assert!(!subdir.exists());
1240    }
1241
1242    #[test]
1243    fn delete_path_sandbox_violation() {
1244        let dir = temp_dir();
1245        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1246        let params = make_params(&[("path", serde_json::json!("/etc/hosts"))]);
1247        let result = exec.execute_file_tool("delete_path", &params);
1248        assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
1249    }
1250
1251    #[test]
1252    fn delete_path_refuses_sandbox_root() {
1253        let dir = temp_dir();
1254        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1255        let params = make_params(&[
1256            ("path", serde_json::json!(dir.path().to_str().unwrap())),
1257            ("recursive", serde_json::json!(true)),
1258        ]);
1259        let result = exec.execute_file_tool("delete_path", &params);
1260        assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
1261    }
1262
1263    // --- move_path tests ---
1264
1265    #[test]
1266    fn move_path_renames_file() {
1267        let dir = temp_dir();
1268        let src = dir.path().join("src.txt");
1269        let dst = dir.path().join("dst.txt");
1270        fs::write(&src, "data").unwrap();
1271
1272        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1273        let params = make_params(&[
1274            ("source", serde_json::json!(src.to_str().unwrap())),
1275            ("destination", serde_json::json!(dst.to_str().unwrap())),
1276        ]);
1277        exec.execute_file_tool("move_path", &params)
1278            .unwrap()
1279            .unwrap();
1280        assert!(!src.exists());
1281        assert_eq!(fs::read_to_string(&dst).unwrap(), "data");
1282    }
1283
1284    #[test]
1285    fn move_path_cross_sandbox_denied() {
1286        let sandbox = temp_dir();
1287        let outside = temp_dir();
1288        let src = sandbox.path().join("src.txt");
1289        fs::write(&src, "x").unwrap();
1290
1291        let exec = FileExecutor::new(vec![sandbox.path().to_path_buf()]);
1292        let dst = outside.path().join("dst.txt");
1293        let params = make_params(&[
1294            ("source", serde_json::json!(src.to_str().unwrap())),
1295            ("destination", serde_json::json!(dst.to_str().unwrap())),
1296        ]);
1297        let result = exec.execute_file_tool("move_path", &params);
1298        assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
1299    }
1300
1301    // --- copy_path tests ---
1302
1303    #[test]
1304    fn copy_path_file() {
1305        let dir = temp_dir();
1306        let src = dir.path().join("src.txt");
1307        let dst = dir.path().join("dst.txt");
1308        fs::write(&src, "hello").unwrap();
1309
1310        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1311        let params = make_params(&[
1312            ("source", serde_json::json!(src.to_str().unwrap())),
1313            ("destination", serde_json::json!(dst.to_str().unwrap())),
1314        ]);
1315        exec.execute_file_tool("copy_path", &params)
1316            .unwrap()
1317            .unwrap();
1318        assert_eq!(fs::read_to_string(&src).unwrap(), "hello");
1319        assert_eq!(fs::read_to_string(&dst).unwrap(), "hello");
1320    }
1321
1322    #[test]
1323    fn copy_path_directory_recursive() {
1324        let dir = temp_dir();
1325        let src_dir = dir.path().join("src_dir");
1326        fs::create_dir(&src_dir).unwrap();
1327        fs::write(src_dir.join("a.txt"), "aaa").unwrap();
1328
1329        let dst_dir = dir.path().join("dst_dir");
1330
1331        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1332        let params = make_params(&[
1333            ("source", serde_json::json!(src_dir.to_str().unwrap())),
1334            ("destination", serde_json::json!(dst_dir.to_str().unwrap())),
1335        ]);
1336        exec.execute_file_tool("copy_path", &params)
1337            .unwrap()
1338            .unwrap();
1339        assert_eq!(fs::read_to_string(dst_dir.join("a.txt")).unwrap(), "aaa");
1340    }
1341
1342    #[test]
1343    fn copy_path_sandbox_violation() {
1344        let sandbox = temp_dir();
1345        let outside = temp_dir();
1346        let src = sandbox.path().join("src.txt");
1347        fs::write(&src, "x").unwrap();
1348
1349        let exec = FileExecutor::new(vec![sandbox.path().to_path_buf()]);
1350        let dst = outside.path().join("dst.txt");
1351        let params = make_params(&[
1352            ("source", serde_json::json!(src.to_str().unwrap())),
1353            ("destination", serde_json::json!(dst.to_str().unwrap())),
1354        ]);
1355        let result = exec.execute_file_tool("copy_path", &params);
1356        assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
1357    }
1358
1359    // CR-11: invalid glob pattern returns error
1360    #[test]
1361    fn find_path_invalid_pattern_returns_error() {
1362        let dir = temp_dir();
1363        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1364        let params = make_params(&[("pattern", serde_json::json!("[invalid"))]);
1365        let result = exec.execute_file_tool("find_path", &params);
1366        assert!(result.is_err());
1367    }
1368
1369    // CR-12: create_directory is idempotent on existing dir
1370    #[test]
1371    fn create_directory_idempotent() {
1372        let dir = temp_dir();
1373        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1374        let target = dir.path().join("exists");
1375        fs::create_dir(&target).unwrap();
1376
1377        let params = make_params(&[("path", serde_json::json!(target.to_str().unwrap()))]);
1378        let result = exec.execute_file_tool("create_directory", &params);
1379        assert!(result.is_ok());
1380        assert!(target.is_dir());
1381    }
1382
1383    // CR-13: move_path source sandbox violation
1384    #[test]
1385    fn move_path_source_sandbox_violation() {
1386        let sandbox = temp_dir();
1387        let outside = temp_dir();
1388        let src = outside.path().join("src.txt");
1389        fs::write(&src, "x").unwrap();
1390
1391        let exec = FileExecutor::new(vec![sandbox.path().to_path_buf()]);
1392        let dst = sandbox.path().join("dst.txt");
1393        let params = make_params(&[
1394            ("source", serde_json::json!(src.to_str().unwrap())),
1395            ("destination", serde_json::json!(dst.to_str().unwrap())),
1396        ]);
1397        let result = exec.execute_file_tool("move_path", &params);
1398        assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
1399    }
1400
1401    // CR-13: copy_path source sandbox violation
1402    #[test]
1403    fn copy_path_source_sandbox_violation() {
1404        let sandbox = temp_dir();
1405        let outside = temp_dir();
1406        let src = outside.path().join("src.txt");
1407        fs::write(&src, "x").unwrap();
1408
1409        let exec = FileExecutor::new(vec![sandbox.path().to_path_buf()]);
1410        let dst = sandbox.path().join("dst.txt");
1411        let params = make_params(&[
1412            ("source", serde_json::json!(src.to_str().unwrap())),
1413            ("destination", serde_json::json!(dst.to_str().unwrap())),
1414        ]);
1415        let result = exec.execute_file_tool("copy_path", &params);
1416        assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
1417    }
1418
1419    // CR-01: copy_dir_recursive skips symlinks
1420    #[cfg(unix)]
1421    #[test]
1422    fn copy_dir_skips_symlinks() {
1423        let dir = temp_dir();
1424        let src_dir = dir.path().join("src");
1425        fs::create_dir(&src_dir).unwrap();
1426        fs::write(src_dir.join("real.txt"), "real").unwrap();
1427
1428        // Create a symlink inside src pointing outside sandbox
1429        let outside = temp_dir();
1430        std::os::unix::fs::symlink(outside.path(), src_dir.join("link")).unwrap();
1431
1432        let dst_dir = dir.path().join("dst");
1433        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1434        let params = make_params(&[
1435            ("source", serde_json::json!(src_dir.to_str().unwrap())),
1436            ("destination", serde_json::json!(dst_dir.to_str().unwrap())),
1437        ]);
1438        exec.execute_file_tool("copy_path", &params)
1439            .unwrap()
1440            .unwrap();
1441        // Real file copied
1442        assert_eq!(
1443            fs::read_to_string(dst_dir.join("real.txt")).unwrap(),
1444            "real"
1445        );
1446        // Symlink not copied
1447        assert!(!dst_dir.join("link").exists());
1448    }
1449
1450    // CR-04: list_directory detects symlinks
1451    #[cfg(unix)]
1452    #[test]
1453    fn list_directory_shows_symlinks() {
1454        let dir = temp_dir();
1455        let target = dir.path().join("target.txt");
1456        fs::write(&target, "x").unwrap();
1457        std::os::unix::fs::symlink(&target, dir.path().join("link")).unwrap();
1458
1459        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1460        let params = make_params(&[("path", serde_json::json!(dir.path().to_str().unwrap()))]);
1461        let result = exec
1462            .execute_file_tool("list_directory", &params)
1463            .unwrap()
1464            .unwrap();
1465        assert!(result.summary.contains("[symlink] link"));
1466        assert!(result.summary.contains("[file] target.txt"));
1467    }
1468
1469    #[test]
1470    fn tilde_path_is_expanded() {
1471        let exec = FileExecutor::new(vec![PathBuf::from("~/nonexistent_subdir_for_test")]);
1472        assert!(
1473            !exec.allowed_paths[0].to_string_lossy().starts_with('~'),
1474            "tilde was not expanded: {:?}",
1475            exec.allowed_paths[0]
1476        );
1477    }
1478
1479    #[test]
1480    fn absolute_path_unchanged() {
1481        let exec = FileExecutor::new(vec![PathBuf::from("/tmp")]);
1482        // On macOS /tmp is a symlink to /private/tmp; canonicalize resolves it.
1483        // The invariant is that the result is absolute and tilde-free.
1484        let p = exec.allowed_paths[0].to_string_lossy();
1485        assert!(
1486            p.starts_with('/'),
1487            "expected absolute path, got: {:?}",
1488            exec.allowed_paths[0]
1489        );
1490        assert!(
1491            !p.starts_with('~'),
1492            "tilde must not appear in result: {:?}",
1493            exec.allowed_paths[0]
1494        );
1495    }
1496
1497    #[test]
1498    fn tilde_only_expands_to_home() {
1499        let exec = FileExecutor::new(vec![PathBuf::from("~")]);
1500        assert!(
1501            !exec.allowed_paths[0].to_string_lossy().starts_with('~'),
1502            "bare tilde was not expanded: {:?}",
1503            exec.allowed_paths[0]
1504        );
1505    }
1506
1507    #[test]
1508    fn empty_allowed_paths_uses_cwd() {
1509        let exec = FileExecutor::new(vec![]);
1510        assert!(
1511            !exec.allowed_paths.is_empty(),
1512            "expected cwd fallback, got empty allowed_paths"
1513        );
1514    }
1515
1516    // --- normalize_path tests ---
1517
1518    #[test]
1519    fn normalize_path_normal_path() {
1520        assert_eq!(
1521            normalize_path(Path::new("/tmp/sandbox/file.txt")),
1522            PathBuf::from("/tmp/sandbox/file.txt")
1523        );
1524    }
1525
1526    #[test]
1527    fn normalize_path_collapses_dot() {
1528        assert_eq!(
1529            normalize_path(Path::new("/tmp/sandbox/./file.txt")),
1530            PathBuf::from("/tmp/sandbox/file.txt")
1531        );
1532    }
1533
1534    #[test]
1535    fn normalize_path_collapses_dotdot() {
1536        assert_eq!(
1537            normalize_path(Path::new("/tmp/sandbox/nonexistent/../../etc/passwd")),
1538            PathBuf::from("/tmp/etc/passwd")
1539        );
1540    }
1541
1542    #[test]
1543    fn normalize_path_nested_dotdot() {
1544        assert_eq!(
1545            normalize_path(Path::new("/tmp/sandbox/a/b/../../../etc/passwd")),
1546            PathBuf::from("/tmp/etc/passwd")
1547        );
1548    }
1549
1550    #[test]
1551    fn normalize_path_at_sandbox_boundary() {
1552        assert_eq!(
1553            normalize_path(Path::new("/tmp/sandbox")),
1554            PathBuf::from("/tmp/sandbox")
1555        );
1556    }
1557
1558    // --- validate_path dotdot bypass tests ---
1559
1560    #[test]
1561    fn validate_path_dotdot_bypass_nonexistent_blocked() {
1562        let dir = temp_dir();
1563        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1564        // /sandbox/nonexistent/../../etc/passwd normalizes to /etc/passwd — must be blocked
1565        let escape = format!("{}/nonexistent/../../etc/passwd", dir.path().display());
1566        let params = make_params(&[("path", serde_json::json!(escape))]);
1567        let result = exec.execute_file_tool("read", &params);
1568        assert!(
1569            matches!(result, Err(ToolError::SandboxViolation { .. })),
1570            "expected SandboxViolation for dotdot bypass, got {result:?}"
1571        );
1572    }
1573
1574    #[test]
1575    fn validate_path_dotdot_nested_bypass_blocked() {
1576        let dir = temp_dir();
1577        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1578        let escape = format!("{}/a/b/../../../etc/shadow", dir.path().display());
1579        let params = make_params(&[("path", serde_json::json!(escape))]);
1580        let result = exec.execute_file_tool("read", &params);
1581        assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
1582    }
1583
1584    #[test]
1585    fn validate_path_inside_sandbox_passes() {
1586        let dir = temp_dir();
1587        let file = dir.path().join("allowed.txt");
1588        fs::write(&file, "ok").unwrap();
1589        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1590        let params = make_params(&[("path", serde_json::json!(file.to_str().unwrap()))]);
1591        let result = exec.execute_file_tool("read", &params);
1592        assert!(result.is_ok());
1593    }
1594
1595    #[test]
1596    fn validate_path_dot_components_inside_sandbox_passes() {
1597        let dir = temp_dir();
1598        let file = dir.path().join("sub/file.txt");
1599        fs::create_dir_all(dir.path().join("sub")).unwrap();
1600        fs::write(&file, "ok").unwrap();
1601        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1602        let dotpath = format!("{}/sub/./file.txt", dir.path().display());
1603        let params = make_params(&[("path", serde_json::json!(dotpath))]);
1604        let result = exec.execute_file_tool("read", &params);
1605        assert!(result.is_ok());
1606    }
1607
1608    // --- #2489: per-path read allow/deny sandbox tests ---
1609
1610    #[test]
1611    fn read_sandbox_deny_blocks_file() {
1612        let dir = temp_dir();
1613        let secret = dir.path().join(".env");
1614        fs::write(&secret, "SECRET=abc").unwrap();
1615
1616        let config = crate::config::FileConfig {
1617            deny_read: vec!["**/.env".to_owned()],
1618            allow_read: vec![],
1619        };
1620        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]).with_read_sandbox(&config);
1621        let params = make_params(&[("path", serde_json::json!(secret.to_str().unwrap()))]);
1622        let result = exec.execute_file_tool("read", &params);
1623        assert!(
1624            matches!(result, Err(ToolError::SandboxViolation { .. })),
1625            "expected SandboxViolation, got: {result:?}"
1626        );
1627    }
1628
1629    #[test]
1630    fn read_sandbox_allow_overrides_deny() {
1631        let dir = temp_dir();
1632        let public = dir.path().join("public.env");
1633        fs::write(&public, "VAR=ok").unwrap();
1634
1635        let config = crate::config::FileConfig {
1636            deny_read: vec!["**/*.env".to_owned()],
1637            allow_read: vec![format!("**/public.env")],
1638        };
1639        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]).with_read_sandbox(&config);
1640        let params = make_params(&[("path", serde_json::json!(public.to_str().unwrap()))]);
1641        let result = exec.execute_file_tool("read", &params);
1642        assert!(
1643            result.is_ok(),
1644            "allow override should permit read: {result:?}"
1645        );
1646    }
1647
1648    #[test]
1649    fn read_sandbox_empty_deny_allows_all() {
1650        let dir = temp_dir();
1651        let file = dir.path().join("data.txt");
1652        fs::write(&file, "data").unwrap();
1653
1654        let config = crate::config::FileConfig::default();
1655        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]).with_read_sandbox(&config);
1656        let params = make_params(&[("path", serde_json::json!(file.to_str().unwrap()))]);
1657        let result = exec.execute_file_tool("read", &params);
1658        assert!(result.is_ok(), "empty deny should allow all: {result:?}");
1659    }
1660
1661    #[test]
1662    fn read_sandbox_grep_skips_denied_files() {
1663        let dir = temp_dir();
1664        let allowed = dir.path().join("allowed.txt");
1665        let denied = dir.path().join(".env");
1666        fs::write(&allowed, "needle").unwrap();
1667        fs::write(&denied, "needle").unwrap();
1668
1669        let config = crate::config::FileConfig {
1670            deny_read: vec!["**/.env".to_owned()],
1671            allow_read: vec![],
1672        };
1673        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]).with_read_sandbox(&config);
1674        let params = make_params(&[
1675            ("pattern", serde_json::json!("needle")),
1676            ("path", serde_json::json!(dir.path().to_str().unwrap())),
1677        ]);
1678        let result = exec.execute_file_tool("grep", &params).unwrap().unwrap();
1679        // Should find match in allowed.txt but not in .env
1680        assert!(
1681            result.summary.contains("allowed.txt"),
1682            "expected match in allowed.txt: {}",
1683            result.summary
1684        );
1685        assert!(
1686            !result.summary.contains(".env"),
1687            "should not match in denied .env: {}",
1688            result.summary
1689        );
1690    }
1691}