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