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