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