1mod builtin;
13mod process;
14mod registry;
15pub mod task;
16mod types;
17
18pub use registry::ToolRegistry;
19pub use task::{
20 parallel_task_params_schema, task_params_schema, ParallelTaskParams, ParallelTaskTool,
21 TaskExecutor, TaskParams, TaskResult,
22};
23pub use types::{Tool, ToolContext, ToolEventSender, ToolOutput, ToolStreamEvent};
24
25use crate::file_history::{self, FileHistory};
26use crate::llm::ToolDefinition;
27use crate::permissions::{PermissionChecker, PermissionDecision};
28use anyhow::Result;
29use serde::{Deserialize, Serialize};
30use std::path::PathBuf;
31use std::sync::Arc;
32
33pub const MAX_OUTPUT_SIZE: usize = 100 * 1024; pub const MAX_READ_LINES: usize = 2000;
38
39pub const MAX_LINE_LENGTH: usize = 2000;
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct ToolResult {
45 pub name: String,
46 pub output: String,
47 pub exit_code: i32,
48 #[serde(skip_serializing_if = "Option::is_none")]
49 pub metadata: Option<serde_json::Value>,
50 #[serde(skip)]
52 pub images: Vec<crate::llm::Attachment>,
53}
54
55impl ToolResult {
56 pub fn success(name: &str, output: String) -> Self {
57 Self {
58 name: name.to_string(),
59 output,
60 exit_code: 0,
61 metadata: None,
62 images: Vec::new(),
63 }
64 }
65
66 pub fn error(name: &str, message: String) -> Self {
67 Self {
68 name: name.to_string(),
69 output: message,
70 exit_code: 1,
71 metadata: None,
72 images: Vec::new(),
73 }
74 }
75}
76
77impl From<ToolOutput> for ToolResult {
78 fn from(output: ToolOutput) -> Self {
79 Self {
80 name: String::new(),
81 output: output.content,
82 exit_code: if output.success { 0 } else { 1 },
83 metadata: output.metadata,
84 images: output.images,
85 }
86 }
87}
88
89pub struct ToolExecutor {
98 workspace: PathBuf,
99 registry: Arc<ToolRegistry>,
100 file_history: Arc<FileHistory>,
101 guard_policy: Option<Arc<dyn PermissionChecker>>,
102}
103
104impl ToolExecutor {
105 pub fn new(workspace: String) -> Self {
106 let workspace_path = PathBuf::from(&workspace);
107 let registry = Arc::new(ToolRegistry::new(workspace_path.clone()));
108
109 builtin::register_builtins(®istry);
111 builtin::register_batch(®istry);
113
114 Self {
115 workspace: workspace_path,
116 registry,
117 file_history: Arc::new(FileHistory::new(500)),
118 guard_policy: None,
119 }
120 }
121
122 pub fn set_guard_policy(&mut self, policy: Arc<dyn PermissionChecker>) {
123 self.guard_policy = Some(policy);
124 }
125
126 fn check_guard(&self, name: &str, args: &serde_json::Value) -> Result<()> {
127 if let Some(checker) = &self.guard_policy {
128 if checker.check(name, args) == PermissionDecision::Deny {
129 anyhow::bail!(
130 "Defense-in-depth: Tool '{}' is blocked by guard permission policy",
131 name
132 );
133 }
134 }
135 Ok(())
136 }
137
138 fn check_workspace_boundary(
139 name: &str,
140 args: &serde_json::Value,
141 ctx: &ToolContext,
142 ) -> Result<()> {
143 let path_field = match name {
144 "read" | "write" | "edit" | "patch" => Some("file_path"),
145 "ls" | "grep" | "glob" => Some("path"),
146 _ => None,
147 };
148
149 if let Some(field) = path_field {
150 if let Some(path_str) = args.get(field).and_then(|v| v.as_str()) {
151 let target = if std::path::Path::new(path_str).is_absolute() {
152 std::path::PathBuf::from(path_str)
153 } else {
154 ctx.workspace.join(path_str)
155 };
156
157 if let (Ok(canonical_target), Ok(canonical_workspace)) = (
158 target.canonicalize().or_else(|_| {
159 target
160 .parent()
161 .and_then(|p| p.canonicalize().ok())
162 .ok_or_else(|| {
163 std::io::Error::new(
164 std::io::ErrorKind::NotFound,
165 "parent not found",
166 )
167 })
168 }),
169 ctx.workspace.canonicalize(),
170 ) {
171 if !canonical_target.starts_with(&canonical_workspace) {
172 anyhow::bail!(
173 "Workspace boundary violation: tool '{}' path '{}' escapes workspace '{}'",
174 name,
175 path_str,
176 ctx.workspace.display()
177 );
178 }
179 }
180 }
181 }
182
183 Ok(())
184 }
185
186 pub fn workspace(&self) -> &PathBuf {
187 &self.workspace
188 }
189
190 pub fn registry(&self) -> &Arc<ToolRegistry> {
191 &self.registry
192 }
193
194 pub fn register_dynamic_tool(&self, tool: Arc<dyn Tool>) {
195 self.registry.register(tool);
196 }
197
198 pub fn unregister_dynamic_tool(&self, name: &str) {
199 self.registry.unregister(name);
200 }
201
202 pub fn unregister_tools_by_prefix(&self, prefix: &str) {
204 self.registry.unregister_by_prefix(prefix);
205 }
206
207 pub fn file_history(&self) -> &Arc<FileHistory> {
208 &self.file_history
209 }
210
211 fn capture_snapshot(&self, name: &str, args: &serde_json::Value) {
212 if let Some(file_path) = file_history::extract_file_path(name, args) {
213 let resolved = self.workspace.join(&file_path);
214 let path_to_read = if resolved.exists() {
215 resolved
216 } else if std::path::Path::new(&file_path).exists() {
217 std::path::PathBuf::from(&file_path)
218 } else {
219 self.file_history.save_snapshot(&file_path, "", name);
220 return;
221 };
222
223 match std::fs::read_to_string(&path_to_read) {
224 Ok(content) => {
225 self.file_history.save_snapshot(&file_path, &content, name);
226 tracing::debug!(
227 "Captured file snapshot for {} before {} (version {})",
228 file_path,
229 name,
230 self.file_history.list_versions(&file_path).len() - 1,
231 );
232 }
233 Err(e) => {
234 tracing::warn!("Failed to capture snapshot for {}: {}", file_path, e);
235 }
236 }
237 }
238 }
239
240 pub async fn execute(&self, name: &str, args: &serde_json::Value) -> Result<ToolResult> {
241 self.check_guard(name, args)?;
242 tracing::info!("Executing tool: {} with args: {}", name, args);
243 self.capture_snapshot(name, args);
244 let mut result = self.registry.execute(name, args).await;
245 if let Ok(ref mut r) = result {
246 self.attach_diff_metadata(name, args, r);
247 }
248 match &result {
249 Ok(r) => tracing::info!("Tool {} completed with exit_code={}", name, r.exit_code),
250 Err(e) => tracing::error!("Tool {} failed: {}", name, e),
251 }
252 result
253 }
254
255 pub async fn execute_with_context(
256 &self,
257 name: &str,
258 args: &serde_json::Value,
259 ctx: &ToolContext,
260 ) -> Result<ToolResult> {
261 self.check_guard(name, args)?;
262 Self::check_workspace_boundary(name, args, ctx)?;
263 tracing::info!("Executing tool: {} with args: {}", name, args);
264 self.capture_snapshot(name, args);
265 let mut result = self.registry.execute_with_context(name, args, ctx).await;
266 if let Ok(ref mut r) = result {
267 self.attach_diff_metadata(name, args, r);
268 }
269 match &result {
270 Ok(r) => tracing::info!("Tool {} completed with exit_code={}", name, r.exit_code),
271 Err(e) => tracing::error!("Tool {} failed: {}", name, e),
272 }
273 result
274 }
275
276 fn attach_diff_metadata(&self, name: &str, args: &serde_json::Value, result: &mut ToolResult) {
277 if !file_history::is_file_modifying_tool(name) {
278 return;
279 }
280 let Some(file_path) = file_history::extract_file_path(name, args) else {
281 return;
282 };
283 let meta = result.metadata.get_or_insert_with(|| serde_json::json!({}));
286 meta["file_path"] = serde_json::Value::String(file_path);
287 }
288
289 pub fn definitions(&self) -> Vec<ToolDefinition> {
290 self.registry.definitions()
291 }
292}
293
294#[cfg(test)]
295mod tests {
296 use super::*;
297
298 #[tokio::test]
299 async fn test_tool_executor_creation() {
300 let executor = ToolExecutor::new("/tmp".to_string());
301 #[cfg(feature = "sandbox")]
304 assert_eq!(executor.registry.len(), 13);
305 #[cfg(not(feature = "sandbox"))]
306 assert_eq!(executor.registry.len(), 12);
307 }
308
309 #[tokio::test]
310 async fn test_unknown_tool() {
311 let executor = ToolExecutor::new("/tmp".to_string());
312 let result = executor
313 .execute("unknown", &serde_json::json!({}))
314 .await
315 .unwrap();
316 assert_eq!(result.exit_code, 1);
317 assert!(result.output.contains("Unknown tool"));
318 }
319
320 #[tokio::test]
321 async fn test_builtin_tools_registered() {
322 let executor = ToolExecutor::new("/tmp".to_string());
323 let definitions = executor.definitions();
324
325 assert!(definitions.iter().any(|t| t.name == "bash"));
326 assert!(definitions.iter().any(|t| t.name == "read"));
327 assert!(definitions.iter().any(|t| t.name == "write"));
328 assert!(definitions.iter().any(|t| t.name == "edit"));
329 assert!(definitions.iter().any(|t| t.name == "grep"));
330 assert!(definitions.iter().any(|t| t.name == "glob"));
331 assert!(definitions.iter().any(|t| t.name == "ls"));
332 assert!(definitions.iter().any(|t| t.name == "patch"));
333 assert!(definitions.iter().any(|t| t.name == "web_fetch"));
334 assert!(definitions.iter().any(|t| t.name == "web_search"));
335 assert!(definitions.iter().any(|t| t.name == "batch"));
336 }
337
338 #[test]
339 fn test_tool_result_success() {
340 let result = ToolResult::success("test_tool", "output text".to_string());
341 assert_eq!(result.name, "test_tool");
342 assert_eq!(result.output, "output text");
343 assert_eq!(result.exit_code, 0);
344 assert!(result.metadata.is_none());
345 }
346
347 #[test]
348 fn test_tool_result_error() {
349 let result = ToolResult::error("test_tool", "error message".to_string());
350 assert_eq!(result.name, "test_tool");
351 assert_eq!(result.output, "error message");
352 assert_eq!(result.exit_code, 1);
353 assert!(result.metadata.is_none());
354 }
355
356 #[test]
357 fn test_tool_result_from_tool_output_success() {
358 let output = ToolOutput {
359 content: "success content".to_string(),
360 success: true,
361 metadata: None,
362 images: Vec::new(),
363 };
364 let result: ToolResult = output.into();
365 assert_eq!(result.output, "success content");
366 assert_eq!(result.exit_code, 0);
367 assert!(result.metadata.is_none());
368 }
369
370 #[test]
371 fn test_tool_result_from_tool_output_failure() {
372 let output = ToolOutput {
373 content: "failure content".to_string(),
374 success: false,
375 metadata: Some(serde_json::json!({"error": "test"})),
376 images: Vec::new(),
377 };
378 let result: ToolResult = output.into();
379 assert_eq!(result.output, "failure content");
380 assert_eq!(result.exit_code, 1);
381 assert_eq!(result.metadata, Some(serde_json::json!({"error": "test"})));
382 }
383
384 #[test]
385 fn test_tool_result_metadata_propagation() {
386 let output = ToolOutput::success("content")
387 .with_metadata(serde_json::json!({"_load_skill": true, "skill_name": "test"}));
388 let result: ToolResult = output.into();
389 assert_eq!(result.exit_code, 0);
390 let meta = result.metadata.unwrap();
391 assert_eq!(meta["_load_skill"], true);
392 assert_eq!(meta["skill_name"], "test");
393 }
394
395 #[test]
396 fn test_tool_executor_workspace() {
397 let executor = ToolExecutor::new("/test/workspace".to_string());
398 assert_eq!(executor.workspace().to_str().unwrap(), "/test/workspace");
399 }
400
401 #[test]
402 fn test_tool_executor_registry() {
403 let executor = ToolExecutor::new("/tmp".to_string());
404 let registry = executor.registry();
405 #[cfg(feature = "sandbox")]
408 assert_eq!(registry.len(), 13);
409 #[cfg(not(feature = "sandbox"))]
410 assert_eq!(registry.len(), 12);
411 }
412
413 #[test]
414 fn test_tool_executor_file_history() {
415 let executor = ToolExecutor::new("/tmp".to_string());
416 let history = executor.file_history();
417 assert_eq!(history.list_versions("nonexistent.txt").len(), 0);
418 }
419
420 #[test]
421 fn test_max_output_size_constant() {
422 assert_eq!(MAX_OUTPUT_SIZE, 100 * 1024);
423 }
424
425 #[test]
426 fn test_max_read_lines_constant() {
427 assert_eq!(MAX_READ_LINES, 2000);
428 }
429
430 #[test]
431 fn test_max_line_length_constant() {
432 assert_eq!(MAX_LINE_LENGTH, 2000);
433 }
434
435 #[test]
436 fn test_tool_result_clone() {
437 let result = ToolResult::success("test", "output".to_string());
438 let cloned = result.clone();
439 assert_eq!(result.name, cloned.name);
440 assert_eq!(result.output, cloned.output);
441 assert_eq!(result.exit_code, cloned.exit_code);
442 assert_eq!(result.metadata, cloned.metadata);
443 }
444
445 #[test]
446 fn test_tool_result_debug() {
447 let result = ToolResult::success("test", "output".to_string());
448 let debug_str = format!("{:?}", result);
449 assert!(debug_str.contains("test"));
450 assert!(debug_str.contains("output"));
451 }
452
453 #[tokio::test]
454 async fn test_execute_attaches_diff_metadata() {
455 use tempfile::TempDir;
456 let dir = TempDir::new().unwrap();
457 let file = dir.path().join("hello.txt");
458 std::fs::write(&file, "before content\n").unwrap();
459
460 let executor = ToolExecutor::new(dir.path().to_str().unwrap().to_string());
461 let args = serde_json::json!({
462 "file_path": "hello.txt",
463 "content": "after content\n"
464 });
465 let result = executor.execute("write", &args).await.unwrap();
466
467 let meta = result.metadata.expect("metadata should be present");
468 assert_eq!(meta["before"], "before content\n");
469 assert_eq!(meta["after"], "after content\n");
470 assert_eq!(meta["file_path"], "hello.txt");
471 }
472
473 #[tokio::test]
474 async fn test_execute_with_context_attaches_diff_metadata() {
475 use tempfile::TempDir;
476 let dir = TempDir::new().unwrap();
477 let canonical_dir = dir.path().canonicalize().unwrap();
478 let file = canonical_dir.join("ctx.txt");
479 std::fs::write(&file, "original\n").unwrap();
480
481 let executor = ToolExecutor::new(canonical_dir.to_str().unwrap().to_string());
482 let ctx = ToolContext {
483 workspace: canonical_dir.clone(),
484 session_id: None,
485 event_tx: None,
486 agent_event_tx: None,
487 search_config: None,
488 sandbox: None,
489 };
490 let args = serde_json::json!({
491 "file_path": "ctx.txt",
492 "content": "updated\n"
493 });
494 let result = executor
495 .execute_with_context("write", &args, &ctx)
496 .await
497 .unwrap();
498 assert_eq!(result.exit_code, 0, "write tool failed: {}", result.output);
499
500 let meta = result.metadata.expect("metadata should be present");
501 assert_eq!(meta["before"], "original\n");
502 assert_eq!(meta["after"], "updated\n");
503 assert_eq!(meta["file_path"], "ctx.txt");
504 }
505}