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