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