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