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 async 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).await
219 }
220 "write" => {
221 let p: WriteParams = deserialize_params(params)?;
222 self.handle_write(&p).await
223 }
224 "edit" => {
225 let p: EditParams = deserialize_params(params)?;
226 self.handle_edit(&p).await
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).await
235 }
236 "list_directory" => {
237 let p: ListDirectoryParams = deserialize_params(params)?;
238 self.handle_list_directory(&p).await
239 }
240 "create_directory" => {
241 let p: CreateDirectoryParams = deserialize_params(params)?;
242 self.handle_create_directory(&p).await
243 }
244 "delete_path" => {
245 let p: DeletePathParams = deserialize_params(params)?;
246 self.handle_delete_path(&p).await
247 }
248 "move_path" => {
249 let p: MovePathParams = deserialize_params(params)?;
250 self.handle_move_path(&p).await
251 }
252 "copy_path" => {
253 let p: CopyPathParams = deserialize_params(params)?;
254 self.handle_copy_path(&p).await
255 }
256 _ => Ok(None),
257 }
258 }
259
260 async 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 = tokio::fs::read_to_string(&path).await?;
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 async fn handle_write(&self, params: &WriteParams) -> Result<Option<ToolOutput>, ToolError> {
291 let path = self.validate_path(Path::new(¶ms.path))?;
292 let old_content = tokio::fs::read_to_string(&path).await.unwrap_or_default();
293
294 if let Some(parent) = path.parent() {
295 tokio::fs::create_dir_all(parent).await?;
296 }
297 tokio::fs::write(&path, ¶ms.content).await?;
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 async fn handle_edit(&self, params: &EditParams) -> Result<Option<ToolOutput>, ToolError> {
318 let path = self.validate_path(Path::new(¶ms.path))?;
319 let content = tokio::fs::read_to_string(&path).await?;
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 tokio::fs::write(&path, &new_content).await?;
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 async 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 allowed_paths = self.allowed_paths.clone();
415 let read_deny_globs = self.read_deny_globs.clone();
416 let read_allow_globs = self.read_allow_globs.clone();
417 let results = tokio::task::spawn_blocking(move || {
418 let sandbox = |p: &Path| {
419 let Some(ref deny) = read_deny_globs else {
420 return Ok(());
421 };
422 if deny.is_match(p)
423 && !read_allow_globs
424 .as_ref()
425 .is_some_and(|allow| allow.is_match(p))
426 {
427 return Err(ToolError::SandboxViolation {
428 path: p.display().to_string(),
429 });
430 }
431 Ok(())
432 };
433 let canonical = path.canonicalize().unwrap_or_else(|_| path.clone());
435 if !allowed_paths.iter().any(|a| canonical.starts_with(a)) {
436 return Err(ToolError::SandboxViolation {
437 path: path.display().to_string(),
438 });
439 }
440 let mut results = Vec::new();
441 grep_recursive(&path, ®ex, &mut results, 100, &sandbox)?;
442 Ok(results)
443 })
444 .await
445 .map_err(|e| ToolError::Execution(std::io::Error::other(e.to_string())))??;
446
447 Ok(Some(ToolOutput {
448 tool_name: ToolName::new("grep"),
449 summary: if results.is_empty() {
450 format!("No matches for: {}", params.pattern)
451 } else {
452 results.join("\n")
453 },
454 blocks_executed: 1,
455 filter_stats: None,
456 diff: None,
457 streamed: false,
458 terminal_id: None,
459 locations: None,
460 raw_response: None,
461 claim_source: Some(ClaimSource::FileSystem),
462 }))
463 }
464
465 async fn handle_list_directory(
466 &self,
467 params: &ListDirectoryParams,
468 ) -> Result<Option<ToolOutput>, ToolError> {
469 let path = self.validate_path(Path::new(¶ms.path))?;
470
471 let meta = tokio::fs::metadata(&path).await?;
472 if !meta.is_dir() {
473 return Err(ToolError::Execution(std::io::Error::new(
474 std::io::ErrorKind::NotADirectory,
475 format!("{} is not a directory", params.path),
476 )));
477 }
478
479 let mut dirs = Vec::new();
480 let mut files = Vec::new();
481 let mut symlinks = Vec::new();
482
483 let mut read_dir = tokio::fs::read_dir(&path).await?;
484 while let Some(entry) = read_dir.next_entry().await? {
485 let name = entry.file_name().to_string_lossy().into_owned();
486 let entry_path = entry.path();
488 let meta = tokio::task::spawn_blocking(move || std::fs::symlink_metadata(&entry_path))
489 .await
490 .map_err(|e| ToolError::Execution(std::io::Error::other(e.to_string())))??;
491 if meta.is_symlink() {
492 symlinks.push(format!("[symlink] {name}"));
493 } else if meta.is_dir() {
494 dirs.push(format!("[dir] {name}"));
495 } else {
496 files.push(format!("[file] {name}"));
497 }
498 }
499
500 dirs.sort();
501 files.sort();
502 symlinks.sort();
503
504 let mut entries = dirs;
505 entries.extend(files);
506 entries.extend(symlinks);
507
508 Ok(Some(ToolOutput {
509 tool_name: ToolName::new("list_directory"),
510 summary: if entries.is_empty() {
511 format!("Empty directory: {}", params.path)
512 } else {
513 entries.join("\n")
514 },
515 blocks_executed: 1,
516 filter_stats: None,
517 diff: None,
518 streamed: false,
519 terminal_id: None,
520 locations: None,
521 raw_response: None,
522 claim_source: Some(ClaimSource::FileSystem),
523 }))
524 }
525
526 async fn handle_create_directory(
527 &self,
528 params: &CreateDirectoryParams,
529 ) -> Result<Option<ToolOutput>, ToolError> {
530 let path = self.validate_path(Path::new(¶ms.path))?;
531 tokio::fs::create_dir_all(&path).await?;
532
533 Ok(Some(ToolOutput {
534 tool_name: ToolName::new("create_directory"),
535 summary: format!("Created directory: {}", params.path),
536 blocks_executed: 1,
537 filter_stats: None,
538 diff: None,
539 streamed: false,
540 terminal_id: None,
541 locations: None,
542 raw_response: None,
543 claim_source: Some(ClaimSource::FileSystem),
544 }))
545 }
546
547 async fn handle_delete_path(
548 &self,
549 params: &DeletePathParams,
550 ) -> Result<Option<ToolOutput>, ToolError> {
551 let path = self.validate_path(Path::new(¶ms.path))?;
552
553 if self.allowed_paths.iter().any(|a| &path == a) {
555 return Err(ToolError::SandboxViolation {
556 path: path.display().to_string(),
557 });
558 }
559
560 if path.is_dir() {
561 if params.recursive {
562 tokio::fs::remove_dir_all(&path).await?;
565 } else {
566 tokio::fs::remove_dir(&path).await?;
568 }
569 } else {
570 tokio::fs::remove_file(&path).await?;
571 }
572
573 Ok(Some(ToolOutput {
574 tool_name: ToolName::new("delete_path"),
575 summary: format!("Deleted: {}", params.path),
576 blocks_executed: 1,
577 filter_stats: None,
578 diff: None,
579 streamed: false,
580 terminal_id: None,
581 locations: None,
582 raw_response: None,
583 claim_source: Some(ClaimSource::FileSystem),
584 }))
585 }
586
587 async fn handle_move_path(
588 &self,
589 params: &MovePathParams,
590 ) -> Result<Option<ToolOutput>, ToolError> {
591 let src = self.validate_path(Path::new(¶ms.source))?;
592 let dst = self.validate_path(Path::new(¶ms.destination))?;
593 tokio::fs::rename(&src, &dst).await?;
594
595 Ok(Some(ToolOutput {
596 tool_name: ToolName::new("move_path"),
597 summary: format!("Moved: {} -> {}", params.source, params.destination),
598 blocks_executed: 1,
599 filter_stats: None,
600 diff: None,
601 streamed: false,
602 terminal_id: None,
603 locations: None,
604 raw_response: None,
605 claim_source: Some(ClaimSource::FileSystem),
606 }))
607 }
608
609 async fn handle_copy_path(
610 &self,
611 params: &CopyPathParams,
612 ) -> Result<Option<ToolOutput>, ToolError> {
613 let src = self.validate_path(Path::new(¶ms.source))?;
614 let dst = self.validate_path(Path::new(¶ms.destination))?;
615
616 if src.is_dir() {
617 let src2 = src.clone();
618 let dst2 = dst.clone();
619 tokio::task::spawn_blocking(move || copy_dir_recursive(&src2, &dst2))
620 .await
621 .map_err(|e| ToolError::Execution(std::io::Error::other(e.to_string())))??;
622 } else {
623 if let Some(parent) = dst.parent() {
624 tokio::fs::create_dir_all(parent).await?;
625 }
626 tokio::fs::copy(&src, &dst).await?;
627 }
628
629 Ok(Some(ToolOutput {
630 tool_name: ToolName::new("copy_path"),
631 summary: format!("Copied: {} -> {}", params.source, params.destination),
632 blocks_executed: 1,
633 filter_stats: None,
634 diff: None,
635 streamed: false,
636 terminal_id: None,
637 locations: None,
638 raw_response: None,
639 claim_source: Some(ClaimSource::FileSystem),
640 }))
641 }
642}
643
644impl ToolExecutor for FileExecutor {
645 async fn execute(&self, _response: &str) -> Result<Option<ToolOutput>, ToolError> {
646 Ok(None)
647 }
648
649 #[cfg_attr(
650 feature = "profiling",
651 tracing::instrument(name = "tool.file.execute_call", skip_all, fields(tool_id = %call.tool_id))
652 )]
653 async fn execute_tool_call(&self, call: &ToolCall) -> Result<Option<ToolOutput>, ToolError> {
654 self.execute_file_tool(call.tool_id.as_str(), &call.params)
655 .await
656 }
657
658 fn tool_definitions(&self) -> Vec<ToolDef> {
659 vec![
660 ToolDef {
661 id: "read".into(),
662 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(),
663 schema: schemars::schema_for!(ReadParams),
664 invocation: InvocationHint::ToolCall,
665 output_schema: None,
666 },
667 ToolDef {
668 id: "write".into(),
669 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(),
670 schema: schemars::schema_for!(WriteParams),
671 invocation: InvocationHint::ToolCall,
672 output_schema: None,
673 },
674 ToolDef {
675 id: "edit".into(),
676 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(),
677 schema: schemars::schema_for!(EditParams),
678 invocation: InvocationHint::ToolCall,
679 output_schema: None,
680 },
681 ToolDef {
682 id: "find_path".into(),
683 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(),
684 schema: schemars::schema_for!(FindPathParams),
685 invocation: InvocationHint::ToolCall,
686 output_schema: None,
687 },
688 ToolDef {
689 id: "grep".into(),
690 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(),
691 schema: schemars::schema_for!(GrepParams),
692 invocation: InvocationHint::ToolCall,
693 output_schema: None,
694 },
695 ToolDef {
696 id: "list_directory".into(),
697 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(),
698 schema: schemars::schema_for!(ListDirectoryParams),
699 invocation: InvocationHint::ToolCall,
700 output_schema: None,
701 },
702 ToolDef {
703 id: "create_directory".into(),
704 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(),
705 schema: schemars::schema_for!(CreateDirectoryParams),
706 invocation: InvocationHint::ToolCall,
707 output_schema: None,
708 },
709 ToolDef {
710 id: "delete_path".into(),
711 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(),
712 schema: schemars::schema_for!(DeletePathParams),
713 invocation: InvocationHint::ToolCall,
714 output_schema: None,
715 },
716 ToolDef {
717 id: "move_path".into(),
718 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(),
719 schema: schemars::schema_for!(MovePathParams),
720 invocation: InvocationHint::ToolCall,
721 output_schema: None,
722 },
723 ToolDef {
724 id: "copy_path".into(),
725 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(),
726 schema: schemars::schema_for!(CopyPathParams),
727 invocation: InvocationHint::ToolCall,
728 output_schema: None,
729 },
730 ]
731 }
732}
733
734pub(crate) fn normalize_path(path: &Path) -> PathBuf {
738 use std::path::Component;
739 let mut prefix: Option<std::ffi::OsString> = None;
743 let mut stack: Vec<std::ffi::OsString> = Vec::new();
744 for component in path.components() {
745 match component {
746 Component::CurDir => {}
747 Component::ParentDir => {
748 if stack.last().is_some_and(|s| s != "/") {
750 stack.pop();
751 }
752 }
753 Component::Normal(name) => stack.push(name.to_owned()),
754 Component::RootDir => {
755 if prefix.is_none() {
756 stack.clear();
758 stack.push(std::ffi::OsString::from("/"));
759 }
760 }
763 Component::Prefix(p) => {
764 stack.clear();
765 prefix = Some(p.as_os_str().to_owned());
766 }
767 }
768 }
769 if let Some(drive) = prefix {
770 let mut s = drive.to_string_lossy().into_owned();
772 s.push('\\');
773 let mut result = PathBuf::from(s);
774 for part in &stack {
775 result.push(part);
776 }
777 result
778 } else {
779 let mut result = PathBuf::new();
780 for (i, part) in stack.iter().enumerate() {
781 if i == 0 && part == "/" {
782 result.push("/");
783 } else {
784 result.push(part);
785 }
786 }
787 result
788 }
789}
790
791fn resolve_via_ancestors(path: &Path) -> PathBuf {
798 let mut existing = path;
799 let mut suffix = PathBuf::new();
800 while !existing.exists() {
801 if let Some(parent) = existing.parent() {
802 if let Some(name) = existing.file_name() {
803 if suffix.as_os_str().is_empty() {
804 suffix = PathBuf::from(name);
805 } else {
806 suffix = PathBuf::from(name).join(&suffix);
807 }
808 }
809 existing = parent;
810 } else {
811 break;
812 }
813 }
814 let base = existing.canonicalize().unwrap_or(existing.to_path_buf());
815 if suffix.as_os_str().is_empty() {
816 base
817 } else {
818 base.join(&suffix)
819 }
820}
821
822const IGNORED_DIRS: &[&str] = &[".git", "target", "node_modules", ".hg"];
823
824fn grep_recursive(
825 path: &Path,
826 regex: ®ex::Regex,
827 results: &mut Vec<String>,
828 limit: usize,
829 sandbox: &impl Fn(&Path) -> Result<(), ToolError>,
830) -> Result<(), ToolError> {
831 if results.len() >= limit {
832 return Ok(());
833 }
834 if path.is_file() {
835 let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
837 if sandbox(&canonical).is_err() {
838 return Ok(());
839 }
840 if let Ok(content) = std::fs::read_to_string(path) {
841 for (i, line) in content.lines().enumerate() {
842 if regex.is_match(line) {
843 results.push(format!("{}:{}: {line}", path.display(), i + 1));
844 if results.len() >= limit {
845 return Ok(());
846 }
847 }
848 }
849 }
850 } else if path.is_dir() {
851 let entries = std::fs::read_dir(path)?;
852 for entry in entries.flatten() {
853 let p = entry.path();
854 let name = p.file_name().and_then(|n| n.to_str());
855 if name.is_some_and(|n| n.starts_with('.') || IGNORED_DIRS.contains(&n)) {
856 continue;
857 }
858 grep_recursive(&p, regex, results, limit, sandbox)?;
859 }
860 }
861 Ok(())
862}
863
864fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<(), ToolError> {
865 std::fs::create_dir_all(dst)?;
866 for entry in std::fs::read_dir(src)? {
867 let entry = entry?;
868 let meta = std::fs::symlink_metadata(entry.path())?;
872 let src_path = entry.path();
873 let dst_path = dst.join(entry.file_name());
874 if meta.is_dir() {
875 copy_dir_recursive(&src_path, &dst_path)?;
876 } else if meta.is_file() {
877 std::fs::copy(&src_path, &dst_path)?;
878 }
879 }
881 Ok(())
882}
883
884#[cfg(test)]
885mod tests {
886 use super::*;
887 use std::fs;
888
889 fn temp_dir() -> tempfile::TempDir {
890 tempfile::tempdir().unwrap()
891 }
892
893 fn make_params(
894 pairs: &[(&str, serde_json::Value)],
895 ) -> serde_json::Map<String, serde_json::Value> {
896 pairs
897 .iter()
898 .map(|(k, v)| ((*k).to_owned(), v.clone()))
899 .collect()
900 }
901
902 #[tokio::test]
903 async fn read_file() {
904 let dir = temp_dir();
905 let file = dir.path().join("test.txt");
906 fs::write(&file, "line1\nline2\nline3\n").unwrap();
907
908 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
909 let params = make_params(&[("path", serde_json::json!(file.to_str().unwrap()))]);
910 let result = exec
911 .execute_file_tool("read", ¶ms)
912 .await
913 .unwrap()
914 .unwrap();
915 assert_eq!(result.tool_name, "read");
916 assert!(result.summary.contains("line1"));
917 assert!(result.summary.contains("line3"));
918 }
919
920 #[tokio::test]
921 async fn read_with_offset_and_limit() {
922 let dir = temp_dir();
923 let file = dir.path().join("test.txt");
924 fs::write(&file, "a\nb\nc\nd\ne\n").unwrap();
925
926 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
927 let params = make_params(&[
928 ("path", serde_json::json!(file.to_str().unwrap())),
929 ("offset", serde_json::json!(1)),
930 ("limit", serde_json::json!(2)),
931 ]);
932 let result = exec
933 .execute_file_tool("read", ¶ms)
934 .await
935 .unwrap()
936 .unwrap();
937 assert!(result.summary.contains('b'));
938 assert!(result.summary.contains('c'));
939 assert!(!result.summary.contains('a'));
940 assert!(!result.summary.contains('d'));
941 }
942
943 #[tokio::test]
944 async fn write_file() {
945 let dir = temp_dir();
946 let file = dir.path().join("out.txt");
947
948 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
949 let params = make_params(&[
950 ("path", serde_json::json!(file.to_str().unwrap())),
951 ("content", serde_json::json!("hello world")),
952 ]);
953 let result = exec
954 .execute_file_tool("write", ¶ms)
955 .await
956 .unwrap()
957 .unwrap();
958 assert!(result.summary.contains("11 bytes"));
959 assert_eq!(fs::read_to_string(&file).unwrap(), "hello world");
960 }
961
962 #[tokio::test]
963 async fn edit_file() {
964 let dir = temp_dir();
965 let file = dir.path().join("edit.txt");
966 fs::write(&file, "foo bar baz").unwrap();
967
968 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
969 let params = make_params(&[
970 ("path", serde_json::json!(file.to_str().unwrap())),
971 ("old_string", serde_json::json!("bar")),
972 ("new_string", serde_json::json!("qux")),
973 ]);
974 let result = exec
975 .execute_file_tool("edit", ¶ms)
976 .await
977 .unwrap()
978 .unwrap();
979 assert!(result.summary.contains("Edited"));
980 assert_eq!(fs::read_to_string(&file).unwrap(), "foo qux baz");
981 }
982
983 #[tokio::test]
984 async fn edit_not_found() {
985 let dir = temp_dir();
986 let file = dir.path().join("edit.txt");
987 fs::write(&file, "foo bar").unwrap();
988
989 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
990 let params = make_params(&[
991 ("path", serde_json::json!(file.to_str().unwrap())),
992 ("old_string", serde_json::json!("nonexistent")),
993 ("new_string", serde_json::json!("x")),
994 ]);
995 let result = exec.execute_file_tool("edit", ¶ms).await;
996 assert!(result.is_err());
997 }
998
999 #[tokio::test]
1000 async fn sandbox_violation() {
1001 let dir = temp_dir();
1002 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1003 let params = make_params(&[("path", serde_json::json!("/etc/passwd"))]);
1004 let result = exec.execute_file_tool("read", ¶ms).await;
1005 assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
1006 }
1007
1008 #[tokio::test]
1009 async fn unknown_tool_returns_none() {
1010 let exec = FileExecutor::new(vec![]);
1011 let params = serde_json::Map::new();
1012 let result = exec.execute_file_tool("unknown", ¶ms).await.unwrap();
1013 assert!(result.is_none());
1014 }
1015
1016 #[tokio::test]
1017 async fn find_path_finds_files() {
1018 let dir = temp_dir();
1019 fs::write(dir.path().join("a.rs"), "").unwrap();
1020 fs::write(dir.path().join("b.rs"), "").unwrap();
1021
1022 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1023 let pattern = format!("{}/*.rs", dir.path().display());
1024 let params = make_params(&[("pattern", serde_json::json!(pattern))]);
1025 let result = exec
1026 .execute_file_tool("find_path", ¶ms)
1027 .await
1028 .unwrap()
1029 .unwrap();
1030 assert!(result.summary.contains("a.rs"));
1031 assert!(result.summary.contains("b.rs"));
1032 }
1033
1034 #[tokio::test]
1035 async fn grep_finds_matches() {
1036 let dir = temp_dir();
1037 fs::write(
1038 dir.path().join("test.txt"),
1039 "hello world\nfoo bar\nhello again\n",
1040 )
1041 .unwrap();
1042
1043 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1044 let params = make_params(&[
1045 ("pattern", serde_json::json!("hello")),
1046 ("path", serde_json::json!(dir.path().to_str().unwrap())),
1047 ]);
1048 let result = exec
1049 .execute_file_tool("grep", ¶ms)
1050 .await
1051 .unwrap()
1052 .unwrap();
1053 assert!(result.summary.contains("hello world"));
1054 assert!(result.summary.contains("hello again"));
1055 assert!(!result.summary.contains("foo bar"));
1056 }
1057
1058 #[tokio::test]
1059 async fn write_sandbox_bypass_nonexistent_path() {
1060 let dir = temp_dir();
1061 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1062 let params = make_params(&[
1063 ("path", serde_json::json!("/tmp/evil/escape.txt")),
1064 ("content", serde_json::json!("pwned")),
1065 ]);
1066 let result = exec.execute_file_tool("write", ¶ms).await;
1067 assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
1068 assert!(!Path::new("/tmp/evil/escape.txt").exists());
1069 }
1070
1071 #[tokio::test]
1072 async fn find_path_filters_outside_sandbox() {
1073 let sandbox = temp_dir();
1074 let outside = temp_dir();
1075 fs::write(outside.path().join("secret.rs"), "secret").unwrap();
1076
1077 let exec = FileExecutor::new(vec![sandbox.path().to_path_buf()]);
1078 let pattern = format!("{}/*.rs", outside.path().display());
1079 let params = make_params(&[("pattern", serde_json::json!(pattern))]);
1080 let result = exec
1081 .execute_file_tool("find_path", ¶ms)
1082 .await
1083 .unwrap()
1084 .unwrap();
1085 assert!(!result.summary.contains("secret.rs"));
1086 }
1087
1088 #[tokio::test]
1089 async fn tool_executor_execute_tool_call_delegates() {
1090 let dir = temp_dir();
1091 let file = dir.path().join("test.txt");
1092 fs::write(&file, "content").unwrap();
1093
1094 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1095 let call = ToolCall {
1096 tool_id: ToolName::new("read"),
1097 params: make_params(&[("path", serde_json::json!(file.to_str().unwrap()))]),
1098 caller_id: None,
1099 context: None,
1100
1101 tool_call_id: String::new(),
1102 };
1103 let result = exec.execute_tool_call(&call).await.unwrap().unwrap();
1104 assert_eq!(result.tool_name, "read");
1105 assert!(result.summary.contains("content"));
1106 }
1107
1108 #[tokio::test]
1109 async fn tool_executor_tool_definitions_lists_all() {
1110 let exec = FileExecutor::new(vec![]);
1111 let defs = exec.tool_definitions();
1112 let ids: Vec<&str> = defs.iter().map(|d| d.id.as_ref()).collect();
1113 assert!(ids.contains(&"read"));
1114 assert!(ids.contains(&"write"));
1115 assert!(ids.contains(&"edit"));
1116 assert!(ids.contains(&"find_path"));
1117 assert!(ids.contains(&"grep"));
1118 assert!(ids.contains(&"list_directory"));
1119 assert!(ids.contains(&"create_directory"));
1120 assert!(ids.contains(&"delete_path"));
1121 assert!(ids.contains(&"move_path"));
1122 assert!(ids.contains(&"copy_path"));
1123 assert_eq!(defs.len(), 10);
1124 }
1125
1126 #[tokio::test]
1127 async fn grep_relative_path_validated() {
1128 let sandbox = temp_dir();
1129 let exec = FileExecutor::new(vec![sandbox.path().to_path_buf()]);
1130 let params = make_params(&[
1131 ("pattern", serde_json::json!("password")),
1132 ("path", serde_json::json!("../../etc")),
1133 ]);
1134 let result = exec.execute_file_tool("grep", ¶ms).await;
1135 assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
1136 }
1137
1138 #[tokio::test]
1139 async fn tool_definitions_returns_ten_tools() {
1140 let exec = FileExecutor::new(vec![]);
1141 let defs = exec.tool_definitions();
1142 assert_eq!(defs.len(), 10);
1143 let ids: Vec<&str> = defs.iter().map(|d| d.id.as_ref()).collect();
1144 assert_eq!(
1145 ids,
1146 vec![
1147 "read",
1148 "write",
1149 "edit",
1150 "find_path",
1151 "grep",
1152 "list_directory",
1153 "create_directory",
1154 "delete_path",
1155 "move_path",
1156 "copy_path",
1157 ]
1158 );
1159 }
1160
1161 #[tokio::test]
1162 async fn tool_definitions_all_use_tool_call() {
1163 let exec = FileExecutor::new(vec![]);
1164 for def in exec.tool_definitions() {
1165 assert_eq!(def.invocation, InvocationHint::ToolCall);
1166 }
1167 }
1168
1169 #[tokio::test]
1170 async fn tool_definitions_read_schema_has_params() {
1171 let exec = FileExecutor::new(vec![]);
1172 let defs = exec.tool_definitions();
1173 let read = defs.iter().find(|d| d.id.as_ref() == "read").unwrap();
1174 let obj = read.schema.as_object().unwrap();
1175 let props = obj["properties"].as_object().unwrap();
1176 assert!(props.contains_key("path"));
1177 assert!(props.contains_key("offset"));
1178 assert!(props.contains_key("limit"));
1179 }
1180
1181 #[tokio::test]
1182 async fn missing_required_path_returns_invalid_params() {
1183 let dir = temp_dir();
1184 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1185 let params = serde_json::Map::new();
1186 let result = exec.execute_file_tool("read", ¶ms).await;
1187 assert!(matches!(result, Err(ToolError::InvalidParams { .. })));
1188 }
1189
1190 #[tokio::test]
1193 async fn list_directory_returns_entries() {
1194 let dir = temp_dir();
1195 fs::write(dir.path().join("file.txt"), "").unwrap();
1196 fs::create_dir(dir.path().join("subdir")).unwrap();
1197
1198 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1199 let params = make_params(&[("path", serde_json::json!(dir.path().to_str().unwrap()))]);
1200 let result = exec
1201 .execute_file_tool("list_directory", ¶ms)
1202 .await
1203 .unwrap()
1204 .unwrap();
1205 assert!(result.summary.contains("[dir] subdir"));
1206 assert!(result.summary.contains("[file] file.txt"));
1207 let dir_pos = result.summary.find("[dir]").unwrap();
1209 let file_pos = result.summary.find("[file]").unwrap();
1210 assert!(dir_pos < file_pos);
1211 }
1212
1213 #[tokio::test]
1214 async fn list_directory_empty_dir() {
1215 let dir = temp_dir();
1216 let subdir = dir.path().join("empty");
1217 fs::create_dir(&subdir).unwrap();
1218
1219 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1220 let params = make_params(&[("path", serde_json::json!(subdir.to_str().unwrap()))]);
1221 let result = exec
1222 .execute_file_tool("list_directory", ¶ms)
1223 .await
1224 .unwrap()
1225 .unwrap();
1226 assert!(result.summary.contains("Empty directory"));
1227 }
1228
1229 #[tokio::test]
1230 async fn list_directory_sandbox_violation() {
1231 let dir = temp_dir();
1232 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1233 let params = make_params(&[("path", serde_json::json!("/etc"))]);
1234 let result = exec.execute_file_tool("list_directory", ¶ms).await;
1235 assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
1236 }
1237
1238 #[tokio::test]
1239 async fn list_directory_nonexistent_returns_error() {
1240 let dir = temp_dir();
1241 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1242 let missing = dir.path().join("nonexistent");
1243 let params = make_params(&[("path", serde_json::json!(missing.to_str().unwrap()))]);
1244 let result = exec.execute_file_tool("list_directory", ¶ms).await;
1245 assert!(result.is_err());
1246 }
1247
1248 #[tokio::test]
1249 async fn list_directory_on_file_returns_error() {
1250 let dir = temp_dir();
1251 let file = dir.path().join("file.txt");
1252 fs::write(&file, "content").unwrap();
1253
1254 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1255 let params = make_params(&[("path", serde_json::json!(file.to_str().unwrap()))]);
1256 let result = exec.execute_file_tool("list_directory", ¶ms).await;
1257 assert!(result.is_err());
1258 }
1259
1260 #[tokio::test]
1263 async fn create_directory_creates_nested() {
1264 let dir = temp_dir();
1265 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1266 let nested = dir.path().join("a/b/c");
1267 let params = make_params(&[("path", serde_json::json!(nested.to_str().unwrap()))]);
1268 let result = exec
1269 .execute_file_tool("create_directory", ¶ms)
1270 .await
1271 .unwrap()
1272 .unwrap();
1273 assert!(result.summary.contains("Created"));
1274 assert!(nested.is_dir());
1275 }
1276
1277 #[tokio::test]
1278 async fn create_directory_sandbox_violation() {
1279 let dir = temp_dir();
1280 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1281 let params = make_params(&[("path", serde_json::json!("/tmp/evil_dir"))]);
1282 let result = exec.execute_file_tool("create_directory", ¶ms).await;
1283 assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
1284 }
1285
1286 #[tokio::test]
1289 async fn delete_path_file() {
1290 let dir = temp_dir();
1291 let file = dir.path().join("del.txt");
1292 fs::write(&file, "bye").unwrap();
1293
1294 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1295 let params = make_params(&[("path", serde_json::json!(file.to_str().unwrap()))]);
1296 exec.execute_file_tool("delete_path", ¶ms)
1297 .await
1298 .unwrap()
1299 .unwrap();
1300 assert!(!file.exists());
1301 }
1302
1303 #[tokio::test]
1304 async fn delete_path_empty_directory() {
1305 let dir = temp_dir();
1306 let subdir = dir.path().join("empty_sub");
1307 fs::create_dir(&subdir).unwrap();
1308
1309 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1310 let params = make_params(&[("path", serde_json::json!(subdir.to_str().unwrap()))]);
1311 exec.execute_file_tool("delete_path", ¶ms)
1312 .await
1313 .unwrap()
1314 .unwrap();
1315 assert!(!subdir.exists());
1316 }
1317
1318 #[tokio::test]
1319 async fn delete_path_non_empty_dir_without_recursive_fails() {
1320 let dir = temp_dir();
1321 let subdir = dir.path().join("nonempty");
1322 fs::create_dir(&subdir).unwrap();
1323 fs::write(subdir.join("file.txt"), "x").unwrap();
1324
1325 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1326 let params = make_params(&[("path", serde_json::json!(subdir.to_str().unwrap()))]);
1327 let result = exec.execute_file_tool("delete_path", ¶ms).await;
1328 assert!(result.is_err());
1329 }
1330
1331 #[tokio::test]
1332 async fn delete_path_recursive() {
1333 let dir = temp_dir();
1334 let subdir = dir.path().join("recurse");
1335 fs::create_dir(&subdir).unwrap();
1336 fs::write(subdir.join("f.txt"), "x").unwrap();
1337
1338 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1339 let params = make_params(&[
1340 ("path", serde_json::json!(subdir.to_str().unwrap())),
1341 ("recursive", serde_json::json!(true)),
1342 ]);
1343 exec.execute_file_tool("delete_path", ¶ms)
1344 .await
1345 .unwrap()
1346 .unwrap();
1347 assert!(!subdir.exists());
1348 }
1349
1350 #[tokio::test]
1351 async fn delete_path_sandbox_violation() {
1352 let dir = temp_dir();
1353 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1354 let params = make_params(&[("path", serde_json::json!("/etc/hosts"))]);
1355 let result = exec.execute_file_tool("delete_path", ¶ms).await;
1356 assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
1357 }
1358
1359 #[tokio::test]
1360 async fn delete_path_refuses_sandbox_root() {
1361 let dir = temp_dir();
1362 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1363 let params = make_params(&[
1364 ("path", serde_json::json!(dir.path().to_str().unwrap())),
1365 ("recursive", serde_json::json!(true)),
1366 ]);
1367 let result = exec.execute_file_tool("delete_path", ¶ms).await;
1368 assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
1369 }
1370
1371 #[tokio::test]
1374 async fn move_path_renames_file() {
1375 let dir = temp_dir();
1376 let src = dir.path().join("src.txt");
1377 let dst = dir.path().join("dst.txt");
1378 fs::write(&src, "data").unwrap();
1379
1380 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1381 let params = make_params(&[
1382 ("source", serde_json::json!(src.to_str().unwrap())),
1383 ("destination", serde_json::json!(dst.to_str().unwrap())),
1384 ]);
1385 exec.execute_file_tool("move_path", ¶ms)
1386 .await
1387 .unwrap()
1388 .unwrap();
1389 assert!(!src.exists());
1390 assert_eq!(fs::read_to_string(&dst).unwrap(), "data");
1391 }
1392
1393 #[tokio::test]
1394 async fn move_path_cross_sandbox_denied() {
1395 let sandbox = temp_dir();
1396 let outside = temp_dir();
1397 let src = sandbox.path().join("src.txt");
1398 fs::write(&src, "x").unwrap();
1399
1400 let exec = FileExecutor::new(vec![sandbox.path().to_path_buf()]);
1401 let dst = outside.path().join("dst.txt");
1402 let params = make_params(&[
1403 ("source", serde_json::json!(src.to_str().unwrap())),
1404 ("destination", serde_json::json!(dst.to_str().unwrap())),
1405 ]);
1406 let result = exec.execute_file_tool("move_path", ¶ms).await;
1407 assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
1408 }
1409
1410 #[tokio::test]
1413 async fn copy_path_file() {
1414 let dir = temp_dir();
1415 let src = dir.path().join("src.txt");
1416 let dst = dir.path().join("dst.txt");
1417 fs::write(&src, "hello").unwrap();
1418
1419 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1420 let params = make_params(&[
1421 ("source", serde_json::json!(src.to_str().unwrap())),
1422 ("destination", serde_json::json!(dst.to_str().unwrap())),
1423 ]);
1424 exec.execute_file_tool("copy_path", ¶ms)
1425 .await
1426 .unwrap()
1427 .unwrap();
1428 assert_eq!(fs::read_to_string(&src).unwrap(), "hello");
1429 assert_eq!(fs::read_to_string(&dst).unwrap(), "hello");
1430 }
1431
1432 #[tokio::test]
1433 async fn copy_path_directory_recursive() {
1434 let dir = temp_dir();
1435 let src_dir = dir.path().join("src_dir");
1436 fs::create_dir(&src_dir).unwrap();
1437 fs::write(src_dir.join("a.txt"), "aaa").unwrap();
1438
1439 let dst_dir = dir.path().join("dst_dir");
1440
1441 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1442 let params = make_params(&[
1443 ("source", serde_json::json!(src_dir.to_str().unwrap())),
1444 ("destination", serde_json::json!(dst_dir.to_str().unwrap())),
1445 ]);
1446 exec.execute_file_tool("copy_path", ¶ms)
1447 .await
1448 .unwrap()
1449 .unwrap();
1450 assert_eq!(fs::read_to_string(dst_dir.join("a.txt")).unwrap(), "aaa");
1451 }
1452
1453 #[tokio::test]
1454 async fn copy_path_sandbox_violation() {
1455 let sandbox = temp_dir();
1456 let outside = temp_dir();
1457 let src = sandbox.path().join("src.txt");
1458 fs::write(&src, "x").unwrap();
1459
1460 let exec = FileExecutor::new(vec![sandbox.path().to_path_buf()]);
1461 let dst = outside.path().join("dst.txt");
1462 let params = make_params(&[
1463 ("source", serde_json::json!(src.to_str().unwrap())),
1464 ("destination", serde_json::json!(dst.to_str().unwrap())),
1465 ]);
1466 let result = exec.execute_file_tool("copy_path", ¶ms).await;
1467 assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
1468 }
1469
1470 #[tokio::test]
1472 async fn find_path_invalid_pattern_returns_error() {
1473 let dir = temp_dir();
1474 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1475 let params = make_params(&[("pattern", serde_json::json!("[invalid"))]);
1476 let result = exec.execute_file_tool("find_path", ¶ms).await;
1477 assert!(result.is_err());
1478 }
1479
1480 #[tokio::test]
1482 async fn create_directory_idempotent() {
1483 let dir = temp_dir();
1484 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1485 let target = dir.path().join("exists");
1486 fs::create_dir(&target).unwrap();
1487
1488 let params = make_params(&[("path", serde_json::json!(target.to_str().unwrap()))]);
1489 let result = exec.execute_file_tool("create_directory", ¶ms).await;
1490 assert!(result.is_ok());
1491 assert!(target.is_dir());
1492 }
1493
1494 #[tokio::test]
1496 async fn move_path_source_sandbox_violation() {
1497 let sandbox = temp_dir();
1498 let outside = temp_dir();
1499 let src = outside.path().join("src.txt");
1500 fs::write(&src, "x").unwrap();
1501
1502 let exec = FileExecutor::new(vec![sandbox.path().to_path_buf()]);
1503 let dst = sandbox.path().join("dst.txt");
1504 let params = make_params(&[
1505 ("source", serde_json::json!(src.to_str().unwrap())),
1506 ("destination", serde_json::json!(dst.to_str().unwrap())),
1507 ]);
1508 let result = exec.execute_file_tool("move_path", ¶ms).await;
1509 assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
1510 }
1511
1512 #[tokio::test]
1514 async fn copy_path_source_sandbox_violation() {
1515 let sandbox = temp_dir();
1516 let outside = temp_dir();
1517 let src = outside.path().join("src.txt");
1518 fs::write(&src, "x").unwrap();
1519
1520 let exec = FileExecutor::new(vec![sandbox.path().to_path_buf()]);
1521 let dst = sandbox.path().join("dst.txt");
1522 let params = make_params(&[
1523 ("source", serde_json::json!(src.to_str().unwrap())),
1524 ("destination", serde_json::json!(dst.to_str().unwrap())),
1525 ]);
1526 let result = exec.execute_file_tool("copy_path", ¶ms).await;
1527 assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
1528 }
1529
1530 #[cfg(unix)]
1532 #[tokio::test]
1533 async fn copy_dir_skips_symlinks() {
1534 let dir = temp_dir();
1535 let src_dir = dir.path().join("src");
1536 fs::create_dir(&src_dir).unwrap();
1537 fs::write(src_dir.join("real.txt"), "real").unwrap();
1538
1539 let outside = temp_dir();
1541 std::os::unix::fs::symlink(outside.path(), src_dir.join("link")).unwrap();
1542
1543 let dst_dir = dir.path().join("dst");
1544 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1545 let params = make_params(&[
1546 ("source", serde_json::json!(src_dir.to_str().unwrap())),
1547 ("destination", serde_json::json!(dst_dir.to_str().unwrap())),
1548 ]);
1549 exec.execute_file_tool("copy_path", ¶ms)
1550 .await
1551 .unwrap()
1552 .unwrap();
1553 assert_eq!(
1555 fs::read_to_string(dst_dir.join("real.txt")).unwrap(),
1556 "real"
1557 );
1558 assert!(!dst_dir.join("link").exists());
1560 }
1561
1562 #[cfg(unix)]
1564 #[tokio::test]
1565 async fn list_directory_shows_symlinks() {
1566 let dir = temp_dir();
1567 let target = dir.path().join("target.txt");
1568 fs::write(&target, "x").unwrap();
1569 std::os::unix::fs::symlink(&target, dir.path().join("link")).unwrap();
1570
1571 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1572 let params = make_params(&[("path", serde_json::json!(dir.path().to_str().unwrap()))]);
1573 let result = exec
1574 .execute_file_tool("list_directory", ¶ms)
1575 .await
1576 .unwrap()
1577 .unwrap();
1578 assert!(result.summary.contains("[symlink] link"));
1579 assert!(result.summary.contains("[file] target.txt"));
1580 }
1581
1582 #[tokio::test]
1583 async fn tilde_path_is_expanded() {
1584 let exec = FileExecutor::new(vec![PathBuf::from("~/nonexistent_subdir_for_test")]);
1585 assert!(
1586 !exec.allowed_paths[0].to_string_lossy().starts_with('~'),
1587 "tilde was not expanded: {:?}",
1588 exec.allowed_paths[0]
1589 );
1590 }
1591
1592 #[tokio::test]
1593 async fn absolute_path_unchanged() {
1594 let exec = FileExecutor::new(vec![PathBuf::from("/tmp")]);
1595 let p = exec.allowed_paths[0].to_string_lossy();
1598 assert!(
1599 p.starts_with('/'),
1600 "expected absolute path, got: {:?}",
1601 exec.allowed_paths[0]
1602 );
1603 assert!(
1604 !p.starts_with('~'),
1605 "tilde must not appear in result: {:?}",
1606 exec.allowed_paths[0]
1607 );
1608 }
1609
1610 #[tokio::test]
1611 async fn tilde_only_expands_to_home() {
1612 let exec = FileExecutor::new(vec![PathBuf::from("~")]);
1613 assert!(
1614 !exec.allowed_paths[0].to_string_lossy().starts_with('~'),
1615 "bare tilde was not expanded: {:?}",
1616 exec.allowed_paths[0]
1617 );
1618 }
1619
1620 #[tokio::test]
1621 async fn empty_allowed_paths_uses_cwd() {
1622 let exec = FileExecutor::new(vec![]);
1623 assert!(
1624 !exec.allowed_paths.is_empty(),
1625 "expected cwd fallback, got empty allowed_paths"
1626 );
1627 }
1628
1629 #[tokio::test]
1632 async fn normalize_path_normal_path() {
1633 assert_eq!(
1634 normalize_path(Path::new("/tmp/sandbox/file.txt")),
1635 PathBuf::from("/tmp/sandbox/file.txt")
1636 );
1637 }
1638
1639 #[tokio::test]
1640 async fn normalize_path_collapses_dot() {
1641 assert_eq!(
1642 normalize_path(Path::new("/tmp/sandbox/./file.txt")),
1643 PathBuf::from("/tmp/sandbox/file.txt")
1644 );
1645 }
1646
1647 #[tokio::test]
1648 async fn normalize_path_collapses_dotdot() {
1649 assert_eq!(
1650 normalize_path(Path::new("/tmp/sandbox/nonexistent/../../etc/passwd")),
1651 PathBuf::from("/tmp/etc/passwd")
1652 );
1653 }
1654
1655 #[tokio::test]
1656 async fn normalize_path_nested_dotdot() {
1657 assert_eq!(
1658 normalize_path(Path::new("/tmp/sandbox/a/b/../../../etc/passwd")),
1659 PathBuf::from("/tmp/etc/passwd")
1660 );
1661 }
1662
1663 #[tokio::test]
1664 async fn normalize_path_at_sandbox_boundary() {
1665 assert_eq!(
1666 normalize_path(Path::new("/tmp/sandbox")),
1667 PathBuf::from("/tmp/sandbox")
1668 );
1669 }
1670
1671 #[tokio::test]
1674 async fn validate_path_dotdot_bypass_nonexistent_blocked() {
1675 let dir = temp_dir();
1676 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1677 let escape = format!("{}/nonexistent/../../etc/passwd", dir.path().display());
1679 let params = make_params(&[("path", serde_json::json!(escape))]);
1680 let result = exec.execute_file_tool("read", ¶ms).await;
1681 assert!(
1682 matches!(result, Err(ToolError::SandboxViolation { .. })),
1683 "expected SandboxViolation for dotdot bypass, got {result:?}"
1684 );
1685 }
1686
1687 #[tokio::test]
1688 async fn validate_path_dotdot_nested_bypass_blocked() {
1689 let dir = temp_dir();
1690 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1691 let escape = format!("{}/a/b/../../../etc/shadow", dir.path().display());
1692 let params = make_params(&[("path", serde_json::json!(escape))]);
1693 let result = exec.execute_file_tool("read", ¶ms).await;
1694 assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
1695 }
1696
1697 #[tokio::test]
1698 async fn validate_path_inside_sandbox_passes() {
1699 let dir = temp_dir();
1700 let file = dir.path().join("allowed.txt");
1701 fs::write(&file, "ok").unwrap();
1702 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1703 let params = make_params(&[("path", serde_json::json!(file.to_str().unwrap()))]);
1704 let result = exec.execute_file_tool("read", ¶ms).await;
1705 assert!(result.is_ok());
1706 }
1707
1708 #[tokio::test]
1709 async fn validate_path_dot_components_inside_sandbox_passes() {
1710 let dir = temp_dir();
1711 let file = dir.path().join("sub/file.txt");
1712 fs::create_dir_all(dir.path().join("sub")).unwrap();
1713 fs::write(&file, "ok").unwrap();
1714 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1715 let dotpath = format!("{}/sub/./file.txt", dir.path().display());
1716 let params = make_params(&[("path", serde_json::json!(dotpath))]);
1717 let result = exec.execute_file_tool("read", ¶ms).await;
1718 assert!(result.is_ok());
1719 }
1720
1721 #[tokio::test]
1724 async fn read_sandbox_deny_blocks_file() {
1725 let dir = temp_dir();
1726 let secret = dir.path().join(".env");
1727 fs::write(&secret, "SECRET=abc").unwrap();
1728
1729 let config = crate::config::FileConfig {
1730 deny_read: vec!["**/.env".to_owned()],
1731 allow_read: vec![],
1732 };
1733 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]).with_read_sandbox(&config);
1734 let params = make_params(&[("path", serde_json::json!(secret.to_str().unwrap()))]);
1735 let result = exec.execute_file_tool("read", ¶ms).await;
1736 assert!(
1737 matches!(result, Err(ToolError::SandboxViolation { .. })),
1738 "expected SandboxViolation, got: {result:?}"
1739 );
1740 }
1741
1742 #[tokio::test]
1743 async fn read_sandbox_allow_overrides_deny() {
1744 let dir = temp_dir();
1745 let public = dir.path().join("public.env");
1746 fs::write(&public, "VAR=ok").unwrap();
1747
1748 let config = crate::config::FileConfig {
1749 deny_read: vec!["**/*.env".to_owned()],
1750 allow_read: vec![format!("**/public.env")],
1751 };
1752 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]).with_read_sandbox(&config);
1753 let params = make_params(&[("path", serde_json::json!(public.to_str().unwrap()))]);
1754 let result = exec.execute_file_tool("read", ¶ms).await;
1755 assert!(
1756 result.is_ok(),
1757 "allow override should permit read: {result:?}"
1758 );
1759 }
1760
1761 #[tokio::test]
1762 async fn read_sandbox_empty_deny_allows_all() {
1763 let dir = temp_dir();
1764 let file = dir.path().join("data.txt");
1765 fs::write(&file, "data").unwrap();
1766
1767 let config = crate::config::FileConfig::default();
1768 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]).with_read_sandbox(&config);
1769 let params = make_params(&[("path", serde_json::json!(file.to_str().unwrap()))]);
1770 let result = exec.execute_file_tool("read", ¶ms).await;
1771 assert!(result.is_ok(), "empty deny should allow all: {result:?}");
1772 }
1773
1774 #[tokio::test]
1775 async fn read_sandbox_grep_skips_denied_files() {
1776 let dir = temp_dir();
1777 let allowed = dir.path().join("allowed.txt");
1778 let denied = dir.path().join(".env");
1779 fs::write(&allowed, "needle").unwrap();
1780 fs::write(&denied, "needle").unwrap();
1781
1782 let config = crate::config::FileConfig {
1783 deny_read: vec!["**/.env".to_owned()],
1784 allow_read: vec![],
1785 };
1786 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]).with_read_sandbox(&config);
1787 let params = make_params(&[
1788 ("pattern", serde_json::json!("needle")),
1789 ("path", serde_json::json!(dir.path().to_str().unwrap())),
1790 ]);
1791 let result = exec
1792 .execute_file_tool("grep", ¶ms)
1793 .await
1794 .unwrap()
1795 .unwrap();
1796 assert!(
1798 result.summary.contains("allowed.txt"),
1799 "expected match in allowed.txt: {}",
1800 result.summary
1801 );
1802 assert!(
1803 !result.summary.contains(".env"),
1804 "should not match in denied .env: {}",
1805 result.summary
1806 );
1807 }
1808
1809 #[tokio::test]
1810 async fn find_path_truncates_at_default_limit() {
1811 let dir = temp_dir();
1812 for i in 0..205u32 {
1814 fs::write(dir.path().join(format!("file_{i:04}.txt")), "").unwrap();
1815 }
1816 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1817 let pattern = dir.path().join("*.txt").to_str().unwrap().to_owned();
1818 let params = make_params(&[("pattern", serde_json::json!(pattern))]);
1819 let result = exec
1820 .execute_file_tool("find_path", ¶ms)
1821 .await
1822 .unwrap()
1823 .unwrap();
1824 assert!(
1826 result.summary.contains("and more results"),
1827 "expected truncation notice: {}",
1828 &result.summary[..100.min(result.summary.len())]
1829 );
1830 let lines: Vec<&str> = result.summary.lines().collect();
1832 assert_eq!(lines.len(), 201, "expected 200 paths + 1 truncation line");
1833 }
1834
1835 #[tokio::test]
1836 async fn find_path_respects_max_results() {
1837 let dir = temp_dir();
1838 for i in 0..10u32 {
1839 fs::write(dir.path().join(format!("f_{i}.txt")), "").unwrap();
1840 }
1841 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1842 let pattern = dir.path().join("*.txt").to_str().unwrap().to_owned();
1843 let params = make_params(&[
1844 ("pattern", serde_json::json!(pattern)),
1845 ("max_results", serde_json::json!(5)),
1846 ]);
1847 let result = exec
1848 .execute_file_tool("find_path", ¶ms)
1849 .await
1850 .unwrap()
1851 .unwrap();
1852 assert!(result.summary.contains("and more results"));
1853 let paths: Vec<&str> = result
1854 .summary
1855 .lines()
1856 .filter(|l| {
1857 std::path::Path::new(l)
1858 .extension()
1859 .is_some_and(|e| e.eq_ignore_ascii_case("txt"))
1860 })
1861 .collect();
1862 assert_eq!(paths.len(), 5);
1863 }
1864}