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