1use std::path::{Path, PathBuf};
2
3use schemars::JsonSchema;
4use serde::Deserialize;
5
6use crate::executor::{
7 DiffData, ToolCall, ToolError, ToolExecutor, ToolOutput, deserialize_params,
8};
9use crate::registry::{InvocationHint, ToolDef};
10
11#[derive(Deserialize, JsonSchema)]
12pub(crate) struct ReadParams {
13 path: String,
15 offset: Option<u32>,
17 limit: Option<u32>,
19}
20
21#[derive(Deserialize, JsonSchema)]
22struct WriteParams {
23 path: String,
25 content: String,
27}
28
29#[derive(Deserialize, JsonSchema)]
30struct EditParams {
31 path: String,
33 old_string: String,
35 new_string: String,
37}
38
39#[derive(Deserialize, JsonSchema)]
40struct GlobParams {
41 pattern: String,
43}
44
45#[derive(Deserialize, JsonSchema)]
46struct GrepParams {
47 pattern: String,
49 path: Option<String>,
51 case_sensitive: Option<bool>,
53}
54
55#[derive(Debug)]
57pub struct FileExecutor {
58 allowed_paths: Vec<PathBuf>,
59}
60
61impl FileExecutor {
62 #[must_use]
63 pub fn new(allowed_paths: Vec<PathBuf>) -> Self {
64 let paths = if allowed_paths.is_empty() {
65 vec![std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))]
66 } else {
67 allowed_paths
68 };
69 Self {
70 allowed_paths: paths
71 .into_iter()
72 .map(|p| p.canonicalize().unwrap_or(p))
73 .collect(),
74 }
75 }
76
77 fn validate_path(&self, path: &Path) -> Result<PathBuf, ToolError> {
78 let resolved = if path.is_absolute() {
79 path.to_path_buf()
80 } else {
81 std::env::current_dir()
82 .unwrap_or_else(|_| PathBuf::from("."))
83 .join(path)
84 };
85 let canonical = resolve_via_ancestors(&resolved);
86 if !self.allowed_paths.iter().any(|a| canonical.starts_with(a)) {
87 return Err(ToolError::SandboxViolation {
88 path: canonical.display().to_string(),
89 });
90 }
91 Ok(canonical)
92 }
93
94 pub fn execute_file_tool(
100 &self,
101 tool_id: &str,
102 params: &serde_json::Map<String, serde_json::Value>,
103 ) -> Result<Option<ToolOutput>, ToolError> {
104 match tool_id {
105 "read" => {
106 let p: ReadParams = deserialize_params(params)?;
107 self.handle_read(&p)
108 }
109 "write" => {
110 let p: WriteParams = deserialize_params(params)?;
111 self.handle_write(&p)
112 }
113 "edit" => {
114 let p: EditParams = deserialize_params(params)?;
115 self.handle_edit(&p)
116 }
117 "glob" => {
118 let p: GlobParams = deserialize_params(params)?;
119 self.handle_glob(&p)
120 }
121 "grep" => {
122 let p: GrepParams = deserialize_params(params)?;
123 self.handle_grep(&p)
124 }
125 _ => Ok(None),
126 }
127 }
128
129 fn handle_read(&self, params: &ReadParams) -> Result<Option<ToolOutput>, ToolError> {
130 let path = self.validate_path(Path::new(¶ms.path))?;
131 let content = std::fs::read_to_string(&path)?;
132
133 let offset = params.offset.unwrap_or(0) as usize;
134 let limit = params.limit.map_or(usize::MAX, |l| l as usize);
135
136 let selected: Vec<String> = content
137 .lines()
138 .skip(offset)
139 .take(limit)
140 .enumerate()
141 .map(|(i, line)| format!("{:>4}\t{line}", offset + i + 1))
142 .collect();
143
144 Ok(Some(ToolOutput {
145 tool_name: "read".to_owned(),
146 summary: selected.join("\n"),
147 blocks_executed: 1,
148 filter_stats: None,
149 diff: None,
150 streamed: false,
151 }))
152 }
153
154 fn handle_write(&self, params: &WriteParams) -> Result<Option<ToolOutput>, ToolError> {
155 let path = self.validate_path(Path::new(¶ms.path))?;
156 let old_content = std::fs::read_to_string(&path).unwrap_or_default();
157
158 if let Some(parent) = path.parent() {
159 std::fs::create_dir_all(parent)?;
160 }
161 std::fs::write(&path, ¶ms.content)?;
162
163 Ok(Some(ToolOutput {
164 tool_name: "write".to_owned(),
165 summary: format!("Wrote {} bytes to {}", params.content.len(), params.path),
166 blocks_executed: 1,
167 filter_stats: None,
168 diff: Some(DiffData {
169 file_path: params.path.clone(),
170 old_content,
171 new_content: params.content.clone(),
172 }),
173 streamed: false,
174 }))
175 }
176
177 fn handle_edit(&self, params: &EditParams) -> Result<Option<ToolOutput>, ToolError> {
178 let path = self.validate_path(Path::new(¶ms.path))?;
179 let content = std::fs::read_to_string(&path)?;
180
181 if !content.contains(¶ms.old_string) {
182 return Err(ToolError::Execution(std::io::Error::new(
183 std::io::ErrorKind::NotFound,
184 format!("old_string not found in {}", params.path),
185 )));
186 }
187
188 let new_content = content.replacen(¶ms.old_string, ¶ms.new_string, 1);
189 std::fs::write(&path, &new_content)?;
190
191 Ok(Some(ToolOutput {
192 tool_name: "edit".to_owned(),
193 summary: format!("Edited {}", params.path),
194 blocks_executed: 1,
195 filter_stats: None,
196 diff: Some(DiffData {
197 file_path: params.path.clone(),
198 old_content: content,
199 new_content,
200 }),
201 streamed: false,
202 }))
203 }
204
205 fn handle_glob(&self, params: &GlobParams) -> Result<Option<ToolOutput>, ToolError> {
206 let matches: Vec<String> = glob::glob(¶ms.pattern)
207 .map_err(|e| {
208 ToolError::Execution(std::io::Error::new(
209 std::io::ErrorKind::InvalidInput,
210 e.to_string(),
211 ))
212 })?
213 .filter_map(Result::ok)
214 .filter(|p| {
215 let canonical = p.canonicalize().unwrap_or_else(|_| p.clone());
216 self.allowed_paths.iter().any(|a| canonical.starts_with(a))
217 })
218 .map(|p| p.display().to_string())
219 .collect();
220
221 Ok(Some(ToolOutput {
222 tool_name: "glob".to_owned(),
223 summary: if matches.is_empty() {
224 format!("No files matching: {}", params.pattern)
225 } else {
226 matches.join("\n")
227 },
228 blocks_executed: 1,
229 filter_stats: None,
230 diff: None,
231 streamed: false,
232 }))
233 }
234
235 fn handle_grep(&self, params: &GrepParams) -> Result<Option<ToolOutput>, ToolError> {
236 let search_path = params.path.as_deref().unwrap_or(".");
237 let case_sensitive = params.case_sensitive.unwrap_or(true);
238 let path = self.validate_path(Path::new(search_path))?;
239
240 let regex = if case_sensitive {
241 regex::Regex::new(¶ms.pattern)
242 } else {
243 regex::RegexBuilder::new(¶ms.pattern)
244 .case_insensitive(true)
245 .build()
246 }
247 .map_err(|e| {
248 ToolError::Execution(std::io::Error::new(
249 std::io::ErrorKind::InvalidInput,
250 e.to_string(),
251 ))
252 })?;
253
254 let mut results = Vec::new();
255 grep_recursive(&path, ®ex, &mut results, 100)?;
256
257 Ok(Some(ToolOutput {
258 tool_name: "grep".to_owned(),
259 summary: if results.is_empty() {
260 format!("No matches for: {}", params.pattern)
261 } else {
262 results.join("\n")
263 },
264 blocks_executed: 1,
265 filter_stats: None,
266 diff: None,
267 streamed: false,
268 }))
269 }
270}
271
272impl ToolExecutor for FileExecutor {
273 async fn execute(&self, _response: &str) -> Result<Option<ToolOutput>, ToolError> {
274 Ok(None)
275 }
276
277 async fn execute_tool_call(&self, call: &ToolCall) -> Result<Option<ToolOutput>, ToolError> {
278 self.execute_file_tool(&call.tool_id, &call.params)
279 }
280
281 fn tool_definitions(&self) -> Vec<ToolDef> {
282 vec![
283 ToolDef {
284 id: "read",
285 description: "Read file contents with optional offset/limit",
286 schema: schemars::schema_for!(ReadParams),
287 invocation: InvocationHint::ToolCall,
288 },
289 ToolDef {
290 id: "write",
291 description: "Write content to a file",
292 schema: schemars::schema_for!(WriteParams),
293 invocation: InvocationHint::ToolCall,
294 },
295 ToolDef {
296 id: "edit",
297 description: "Replace a string in a file",
298 schema: schemars::schema_for!(EditParams),
299 invocation: InvocationHint::ToolCall,
300 },
301 ToolDef {
302 id: "glob",
303 description: "Find files matching a glob pattern",
304 schema: schemars::schema_for!(GlobParams),
305 invocation: InvocationHint::ToolCall,
306 },
307 ToolDef {
308 id: "grep",
309 description: "Search file contents with regex",
310 schema: schemars::schema_for!(GrepParams),
311 invocation: InvocationHint::ToolCall,
312 },
313 ]
314 }
315}
316
317fn resolve_via_ancestors(path: &Path) -> PathBuf {
319 let mut existing = path;
320 let mut suffix = PathBuf::new();
321 while !existing.exists() {
322 if let Some(parent) = existing.parent() {
323 if let Some(name) = existing.file_name() {
324 if suffix.as_os_str().is_empty() {
325 suffix = PathBuf::from(name);
326 } else {
327 suffix = PathBuf::from(name).join(&suffix);
328 }
329 }
330 existing = parent;
331 } else {
332 break;
333 }
334 }
335 let base = existing.canonicalize().unwrap_or(existing.to_path_buf());
336 if suffix.as_os_str().is_empty() {
337 base
338 } else {
339 base.join(&suffix)
340 }
341}
342
343const IGNORED_DIRS: &[&str] = &[".git", "target", "node_modules", ".hg"];
344
345fn grep_recursive(
346 path: &Path,
347 regex: ®ex::Regex,
348 results: &mut Vec<String>,
349 limit: usize,
350) -> Result<(), ToolError> {
351 if results.len() >= limit {
352 return Ok(());
353 }
354 if path.is_file() {
355 if let Ok(content) = std::fs::read_to_string(path) {
356 for (i, line) in content.lines().enumerate() {
357 if regex.is_match(line) {
358 results.push(format!("{}:{}: {line}", path.display(), i + 1));
359 if results.len() >= limit {
360 return Ok(());
361 }
362 }
363 }
364 }
365 } else if path.is_dir() {
366 let entries = std::fs::read_dir(path)?;
367 for entry in entries.flatten() {
368 let p = entry.path();
369 let name = p.file_name().and_then(|n| n.to_str());
370 if name.is_some_and(|n| n.starts_with('.') || IGNORED_DIRS.contains(&n)) {
371 continue;
372 }
373 grep_recursive(&p, regex, results, limit)?;
374 }
375 }
376 Ok(())
377}
378
379#[cfg(test)]
380mod tests {
381 use super::*;
382 use std::fs;
383
384 fn temp_dir() -> tempfile::TempDir {
385 tempfile::tempdir().unwrap()
386 }
387
388 fn make_params(
389 pairs: &[(&str, serde_json::Value)],
390 ) -> serde_json::Map<String, serde_json::Value> {
391 pairs
392 .iter()
393 .map(|(k, v)| ((*k).to_owned(), v.clone()))
394 .collect()
395 }
396
397 #[test]
398 fn read_file() {
399 let dir = temp_dir();
400 let file = dir.path().join("test.txt");
401 fs::write(&file, "line1\nline2\nline3\n").unwrap();
402
403 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
404 let params = make_params(&[("path", serde_json::json!(file.to_str().unwrap()))]);
405 let result = exec.execute_file_tool("read", ¶ms).unwrap().unwrap();
406 assert_eq!(result.tool_name, "read");
407 assert!(result.summary.contains("line1"));
408 assert!(result.summary.contains("line3"));
409 }
410
411 #[test]
412 fn read_with_offset_and_limit() {
413 let dir = temp_dir();
414 let file = dir.path().join("test.txt");
415 fs::write(&file, "a\nb\nc\nd\ne\n").unwrap();
416
417 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
418 let params = make_params(&[
419 ("path", serde_json::json!(file.to_str().unwrap())),
420 ("offset", serde_json::json!(1)),
421 ("limit", serde_json::json!(2)),
422 ]);
423 let result = exec.execute_file_tool("read", ¶ms).unwrap().unwrap();
424 assert!(result.summary.contains("b"));
425 assert!(result.summary.contains("c"));
426 assert!(!result.summary.contains("a"));
427 assert!(!result.summary.contains("d"));
428 }
429
430 #[test]
431 fn write_file() {
432 let dir = temp_dir();
433 let file = dir.path().join("out.txt");
434
435 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
436 let params = make_params(&[
437 ("path", serde_json::json!(file.to_str().unwrap())),
438 ("content", serde_json::json!("hello world")),
439 ]);
440 let result = exec.execute_file_tool("write", ¶ms).unwrap().unwrap();
441 assert!(result.summary.contains("11 bytes"));
442 assert_eq!(fs::read_to_string(&file).unwrap(), "hello world");
443 }
444
445 #[test]
446 fn edit_file() {
447 let dir = temp_dir();
448 let file = dir.path().join("edit.txt");
449 fs::write(&file, "foo bar baz").unwrap();
450
451 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
452 let params = make_params(&[
453 ("path", serde_json::json!(file.to_str().unwrap())),
454 ("old_string", serde_json::json!("bar")),
455 ("new_string", serde_json::json!("qux")),
456 ]);
457 let result = exec.execute_file_tool("edit", ¶ms).unwrap().unwrap();
458 assert!(result.summary.contains("Edited"));
459 assert_eq!(fs::read_to_string(&file).unwrap(), "foo qux baz");
460 }
461
462 #[test]
463 fn edit_not_found() {
464 let dir = temp_dir();
465 let file = dir.path().join("edit.txt");
466 fs::write(&file, "foo bar").unwrap();
467
468 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
469 let params = make_params(&[
470 ("path", serde_json::json!(file.to_str().unwrap())),
471 ("old_string", serde_json::json!("nonexistent")),
472 ("new_string", serde_json::json!("x")),
473 ]);
474 let result = exec.execute_file_tool("edit", ¶ms);
475 assert!(result.is_err());
476 }
477
478 #[test]
479 fn sandbox_violation() {
480 let dir = temp_dir();
481 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
482 let params = make_params(&[("path", serde_json::json!("/etc/passwd"))]);
483 let result = exec.execute_file_tool("read", ¶ms);
484 assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
485 }
486
487 #[test]
488 fn unknown_tool_returns_none() {
489 let exec = FileExecutor::new(vec![]);
490 let params = serde_json::Map::new();
491 let result = exec.execute_file_tool("unknown", ¶ms).unwrap();
492 assert!(result.is_none());
493 }
494
495 #[test]
496 fn glob_finds_files() {
497 let dir = temp_dir();
498 fs::write(dir.path().join("a.rs"), "").unwrap();
499 fs::write(dir.path().join("b.rs"), "").unwrap();
500
501 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
502 let pattern = format!("{}/*.rs", dir.path().display());
503 let params = make_params(&[("pattern", serde_json::json!(pattern))]);
504 let result = exec.execute_file_tool("glob", ¶ms).unwrap().unwrap();
505 assert!(result.summary.contains("a.rs"));
506 assert!(result.summary.contains("b.rs"));
507 }
508
509 #[test]
510 fn grep_finds_matches() {
511 let dir = temp_dir();
512 fs::write(
513 dir.path().join("test.txt"),
514 "hello world\nfoo bar\nhello again\n",
515 )
516 .unwrap();
517
518 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
519 let params = make_params(&[
520 ("pattern", serde_json::json!("hello")),
521 ("path", serde_json::json!(dir.path().to_str().unwrap())),
522 ]);
523 let result = exec.execute_file_tool("grep", ¶ms).unwrap().unwrap();
524 assert!(result.summary.contains("hello world"));
525 assert!(result.summary.contains("hello again"));
526 assert!(!result.summary.contains("foo bar"));
527 }
528
529 #[test]
530 fn write_sandbox_bypass_nonexistent_path() {
531 let dir = temp_dir();
532 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
533 let params = make_params(&[
534 ("path", serde_json::json!("/tmp/evil/escape.txt")),
535 ("content", serde_json::json!("pwned")),
536 ]);
537 let result = exec.execute_file_tool("write", ¶ms);
538 assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
539 assert!(!Path::new("/tmp/evil/escape.txt").exists());
540 }
541
542 #[test]
543 fn glob_filters_outside_sandbox() {
544 let sandbox = temp_dir();
545 let outside = temp_dir();
546 fs::write(outside.path().join("secret.rs"), "secret").unwrap();
547
548 let exec = FileExecutor::new(vec![sandbox.path().to_path_buf()]);
549 let pattern = format!("{}/*.rs", outside.path().display());
550 let params = make_params(&[("pattern", serde_json::json!(pattern))]);
551 let result = exec.execute_file_tool("glob", ¶ms).unwrap().unwrap();
552 assert!(!result.summary.contains("secret.rs"));
553 }
554
555 #[tokio::test]
556 async fn tool_executor_execute_tool_call_delegates() {
557 let dir = temp_dir();
558 let file = dir.path().join("test.txt");
559 fs::write(&file, "content").unwrap();
560
561 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
562 let call = ToolCall {
563 tool_id: "read".to_owned(),
564 params: make_params(&[("path", serde_json::json!(file.to_str().unwrap()))]),
565 };
566 let result = exec.execute_tool_call(&call).await.unwrap().unwrap();
567 assert_eq!(result.tool_name, "read");
568 assert!(result.summary.contains("content"));
569 }
570
571 #[test]
572 fn tool_executor_tool_definitions_lists_all() {
573 let exec = FileExecutor::new(vec![]);
574 let defs = exec.tool_definitions();
575 let ids: Vec<&str> = defs.iter().map(|d| d.id).collect();
576 assert!(ids.contains(&"read"));
577 assert!(ids.contains(&"write"));
578 assert!(ids.contains(&"edit"));
579 assert!(ids.contains(&"glob"));
580 assert!(ids.contains(&"grep"));
581 assert_eq!(defs.len(), 5);
582 }
583
584 #[test]
585 fn grep_relative_path_validated() {
586 let sandbox = temp_dir();
587 let exec = FileExecutor::new(vec![sandbox.path().to_path_buf()]);
588 let params = make_params(&[
589 ("pattern", serde_json::json!("password")),
590 ("path", serde_json::json!("../../etc")),
591 ]);
592 let result = exec.execute_file_tool("grep", ¶ms);
593 assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
594 }
595
596 #[test]
597 fn tool_definitions_returns_five_tools() {
598 let exec = FileExecutor::new(vec![]);
599 let defs = exec.tool_definitions();
600 assert_eq!(defs.len(), 5);
601 let ids: Vec<&str> = defs.iter().map(|d| d.id).collect();
602 assert_eq!(ids, vec!["read", "write", "edit", "glob", "grep"]);
603 }
604
605 #[test]
606 fn tool_definitions_all_use_tool_call() {
607 let exec = FileExecutor::new(vec![]);
608 for def in exec.tool_definitions() {
609 assert_eq!(def.invocation, InvocationHint::ToolCall);
610 }
611 }
612
613 #[test]
614 fn tool_definitions_read_schema_has_params() {
615 let exec = FileExecutor::new(vec![]);
616 let defs = exec.tool_definitions();
617 let read = defs.iter().find(|d| d.id == "read").unwrap();
618 let obj = read.schema.as_object().unwrap();
619 let props = obj["properties"].as_object().unwrap();
620 assert!(props.contains_key("path"));
621 assert!(props.contains_key("offset"));
622 assert!(props.contains_key("limit"));
623 }
624
625 #[test]
626 fn missing_required_path_returns_invalid_params() {
627 let dir = temp_dir();
628 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
629 let params = serde_json::Map::new();
630 let result = exec.execute_file_tool("read", ¶ms);
631 assert!(matches!(result, Err(ToolError::InvalidParams { .. })));
632 }
633}