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