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