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