1use std::sync::Arc;
2
3use async_trait::async_trait;
4use bamboo_agent_core::{
5 normalize_tool_name, parse_tool_args_best_effort, Tool, ToolCall, ToolError,
6 ToolExecutionContext, ToolExecutor, ToolOutcome, ToolResult, ToolSchema,
7};
8use bamboo_domain::tool_names::{normalize_builtin_alias, resolve_alias};
9
10use crate::guide::{context::GuideBuildContext, EnhancedPromptBuilder, ToolGuide};
11use crate::permission::{check_permissions, PermissionChecker, PermissionError};
12use crate::tools::{
13 BashInputTool, BashOutputTool, BashTool, ConclusionWithOptionsTool, EditTool,
14 EnterPlanModeTool, ExitPlanModeTool, GetFileInfoTool, GlobTool, GrepTool, JsReplTool,
15 KillShellTool, NotebookEditTool, ReadTool, RequestPermissionsTool, SessionNoteTool, SleepTool,
16 TaskTool, ToolRegistry, UpdateGoalTool, WebFetchTool, WebSearchTool, WorkspaceTool, WriteTool,
17};
18use bamboo_llm::Config;
19use tokio::sync::RwLock;
20
21fn preview_for_log(value: &str, max_chars: usize) -> String {
22 let mut iter = value.chars();
23 let mut preview = String::new();
24 for _ in 0..max_chars {
25 match iter.next() {
26 Some(ch) => preview.push(ch),
27 None => break,
28 }
29 }
30 if iter.next().is_some() {
31 preview.push_str("...");
32 }
33 preview.replace('\n', "\\n").replace('\r', "\\r")
34}
35
36fn copy_legacy_arg_if_missing(
37 args: &mut serde_json::Map<String, serde_json::Value>,
38 from: &str,
39 to: &str,
40) {
41 if args.contains_key(to) {
42 return;
43 }
44 if let Some(value) = args.get(from).cloned() {
45 args.insert(to.to_string(), value);
46 }
47}
48
49fn normalize_legacy_builtin_args(
50 raw_tool_name: &str,
51 args: &mut serde_json::Map<String, serde_json::Value>,
52) {
53 match raw_tool_name {
54 "read_file" | "write_file" | "Read" | "Write" | "apply_patch" => {
55 copy_legacy_arg_if_missing(args, "path", "file_path");
56 }
57 "execute_command" | "Bash" => {
58 copy_legacy_arg_if_missing(args, "cmd", "command");
59 }
60 "list_directory" | "Glob" => {
61 let should_default_pattern = raw_tool_name == "list_directory"
62 || args.contains_key("path")
63 || args.contains_key("recursive");
64 if should_default_pattern && !args.contains_key("pattern") {
65 let recursive = args
66 .get("recursive")
67 .and_then(serde_json::Value::as_bool)
68 .unwrap_or(false);
69 let pattern = if recursive { "**/*" } else { "*" };
70 args.insert(
71 "pattern".to_string(),
72 serde_json::Value::String(pattern.to_string()),
73 );
74 }
75 args.remove("recursive");
76 }
77 _ => {}
78 }
79}
80
81fn resolve_registered_tool_name(registry: &ToolRegistry, raw_tool_name: &str) -> String {
82 if registry.get(raw_tool_name).is_some() {
83 return raw_tool_name.to_string();
84 }
85
86 let aliased = normalize_builtin_alias(raw_tool_name);
87 if registry.get(aliased).is_some() {
88 return aliased.to_string();
89 }
90
91 resolve_alias(aliased).unwrap_or(aliased).to_string()
92}
93
94pub struct BuiltinToolExecutor {
96 registry: ToolRegistry,
97 permission_checker: Option<Arc<dyn PermissionChecker>>,
98}
99
100impl BuiltinToolExecutor {
101 pub fn new() -> Self {
103 let registry = ToolRegistry::new();
104 Self::register_builtin_tools(®istry, None);
105 Self {
106 registry,
107 permission_checker: None,
108 }
109 }
110
111 pub fn new_with_permissions(permission_checker: Arc<dyn PermissionChecker>) -> Self {
113 let registry = ToolRegistry::new();
114 Self::register_builtin_tools(®istry, None);
115 Self {
116 registry,
117 permission_checker: Some(permission_checker),
118 }
119 }
120
121 pub fn new_with_config(config: Arc<RwLock<Config>>) -> Self {
126 let registry = ToolRegistry::new();
127 Self::register_builtin_tools(®istry, Some(config));
128 Self {
129 registry,
130 permission_checker: None,
131 }
132 }
133
134 pub fn new_with_config_and_permissions(
136 config: Arc<RwLock<Config>>,
137 permission_checker: Arc<dyn PermissionChecker>,
138 ) -> Self {
139 let registry = ToolRegistry::new();
140 Self::register_builtin_tools(®istry, Some(config));
141 Self {
142 registry,
143 permission_checker: Some(permission_checker),
144 }
145 }
146
147 pub fn with_registry(registry: ToolRegistry) -> Self {
149 Self {
150 registry,
151 permission_checker: None,
152 }
153 }
154
155 pub fn registry(&self) -> &ToolRegistry {
157 &self.registry
158 }
159
160 fn register_builtin_tools(registry: &ToolRegistry, config: Option<Arc<RwLock<Config>>>) {
162 let _ = config;
163 let _ = registry.register(ConclusionWithOptionsTool::new());
165 let _ = registry.register(BashTool::new());
166 let _ = registry.register(BashInputTool::new());
167 let _ = registry.register(BashOutputTool::new());
168 let _ = registry.register(EditTool::new());
169 let _ = registry.register(EnterPlanModeTool::new());
170 let _ = registry.register(ExitPlanModeTool::new());
171 let _ = registry.register(GetFileInfoTool::new());
173 let _ = registry.register(GlobTool::new());
174 let _ = registry.register(GrepTool::new());
175 let _ = registry.register(UpdateGoalTool::new());
176 let _ = registry.register(JsReplTool::new());
177 let _ = registry.register(KillShellTool::new());
178 let _ = registry.register(SessionNoteTool::new());
179 let _ = registry.register(NotebookEditTool::new());
180 let _ = registry.register(ReadTool::new());
181 let _ = registry.register(RequestPermissionsTool::new());
182 let _ = registry.register(SleepTool::new());
183 let _ = registry.register(TaskTool::new());
184 let _ = registry.register(WebFetchTool::new());
185 let _ = registry.register(WebSearchTool::new());
186 let _ = registry.register(WorkspaceTool::new());
188 let _ = registry.register(WriteTool::new());
189 }
190
191 pub fn tool_schemas() -> Vec<ToolSchema> {
193 let registry = ToolRegistry::new();
194 Self::register_builtin_tools(®istry, None);
195 registry.list_tools()
196 }
197
198 pub fn register_tool<T: Tool + 'static>(&self, tool: T) -> Result<(), ToolError> {
200 self.registry
201 .register(tool)
202 .map_err(|e| ToolError::Execution(e.to_string()))
203 }
204
205 pub fn register_tool_with_guide<T, G>(&self, tool: T, guide: G) -> Result<(), ToolError>
207 where
208 T: Tool + 'static,
209 G: ToolGuide + 'static,
210 {
211 self.registry
212 .register_with_guide(tool, guide)
213 .map_err(|e| ToolError::Execution(e.to_string()))
214 }
215
216 pub fn get_guide(&self, tool_name: &str) -> Option<Arc<dyn ToolGuide>> {
218 self.registry.get_guide(tool_name)
219 }
220
221 pub fn build_enhanced_prompt(&self, context: GuideBuildContext) -> String {
223 EnhancedPromptBuilder::build(Some(&self.registry), &self.registry.list_tools(), &context)
224 }
225}
226
227fn permission_error_to_tool_error(error: PermissionError) -> ToolError {
228 match error {
229 PermissionError::CheckFailed(_) => ToolError::InvalidArguments(error.to_string()),
230 _ => ToolError::Execution(error.to_string()),
231 }
232}
233
234impl Default for BuiltinToolExecutor {
235 fn default() -> Self {
236 Self::new()
237 }
238}
239
240#[async_trait]
241impl ToolExecutor for BuiltinToolExecutor {
242 async fn execute(&self, call: &ToolCall) -> Result<ToolResult, ToolError> {
243 self.execute_with_context(call, ToolExecutionContext::none(&call.id))
244 .await
245 }
246
247 async fn execute_with_context(
248 &self,
249 call: &ToolCall,
250 ctx: ToolExecutionContext<'_>,
251 ) -> Result<ToolResult, ToolError> {
252 self.execute_with_context_outcome(call, ctx)
253 .await
254 .map(ToolOutcome::into_tool_result)
255 }
256
257 async fn execute_with_context_outcome(
258 &self,
259 call: &ToolCall,
260 ctx: ToolExecutionContext<'_>,
261 ) -> Result<ToolOutcome, ToolError> {
262 let mut args = if let Some(pre_parsed) = ctx.pre_parsed_args {
272 pre_parsed.clone()
273 } else {
274 let args_raw = call.function.arguments.trim();
275 let (parsed, parse_warning) = parse_tool_args_best_effort(&call.function.arguments);
276 if let Some(warning) = parse_warning {
277 tracing::warn!(
278 "Builtin tool argument parsing fallback applied: session_id={:?}, tool_call_id={}, tool_name={}, args_len={}, args_preview=\"{}\", warning={}",
279 ctx.session_id,
280 call.id,
281 call.function.name,
282 args_raw.len(),
283 preview_for_log(args_raw, 180),
284 warning
285 );
286 }
287 parsed
288 };
289
290 let raw_tool_name = normalize_tool_name(&call.function.name);
291 if let Some(args_obj) = args.as_object_mut() {
292 normalize_legacy_builtin_args(raw_tool_name, args_obj);
293 }
294
295 let tool_name = resolve_registered_tool_name(&self.registry, raw_tool_name);
296
297 let tool = self
299 .registry
300 .get(&tool_name)
301 .ok_or_else(|| ToolError::NotFound(format!("Tool '{}' not found", tool_name)))?;
302
303 if let Some(outcome) = self.check_permissions_for(call, &ctx).await? {
310 return Ok(outcome);
311 }
312
313 let tool_ctx = ctx.to_tool_ctx();
321 tool.invoke(args, tool_ctx).await
322 }
323
324 async fn check_permissions_for(
348 &self,
349 call: &ToolCall,
350 ctx: &ToolExecutionContext<'_>,
351 ) -> Result<Option<ToolOutcome>, ToolError> {
352 let Some(permission_checker) = &self.permission_checker else {
353 return Ok(None);
354 };
355 let hook_permission_override = crate::current_hook_permission_override(&call.id);
356
357 let mut args = if let Some(pre_parsed) = ctx.pre_parsed_args {
362 pre_parsed.clone()
363 } else {
364 parse_tool_args_best_effort(&call.function.arguments).0
365 };
366 let raw_tool_name = normalize_tool_name(&call.function.name);
367 if let Some(args_obj) = args.as_object_mut() {
368 normalize_legacy_builtin_args(raw_tool_name, args_obj);
369 }
370 let tool_name = resolve_registered_tool_name(&self.registry, raw_tool_name);
371
372 if let Some(contexts) =
373 check_permissions(&tool_name, &args).map_err(permission_error_to_tool_error)?
374 {
375 for context in contexts {
376 let resource = context.resource.clone();
377 let operation_summary = context.operation_description.clone();
378 let risk_level = context.risk_level();
379 let permission_type = context.permission_type;
380 let config = permission_checker.permission_config();
381 let proxy = crate::approval::current_approval_proxy();
382 let request = if let Some(config) = config.as_ref() {
383 let supported_decisions = if proxy.is_some() {
387 crate::permission::PermissionRequest::forced_decisions()
388 } else {
389 crate::permission::PermissionRequest::ordinary_decisions(true)
390 };
391 let workspace_path = args
392 .get("workspace_path")
393 .or_else(|| args.get("cwd"))
394 .and_then(|value| value.as_str())
395 .map(ToOwned::to_owned)
396 .or_else(|| {
397 ctx.session_id
398 .and_then(|session_id| config.session_workspace(session_id))
399 });
400 match config.evaluate(crate::permission::PermissionEvaluation {
401 request_id: call.id.clone(),
402 session_id: ctx.session_id.unwrap_or_default().to_string(),
403 workspace_path,
404 tool_name: tool_name.clone(),
405 tool_args: args.clone(),
406 permission_type,
407 resource: resource.clone(),
408 operation_summary: operation_summary.clone(),
409 risk_level,
410 bypass_requested: ctx.bypass_permissions,
411 platform_hard_deny: None,
412 consume_once: true,
413 supported_decisions,
414 }) {
415 crate::permission::PermissionOutcome::Allow { .. } => continue,
416 crate::permission::PermissionOutcome::Deny { reason, .. } => {
417 return Err(ToolError::Execution(reason.message));
418 }
419 crate::permission::PermissionOutcome::Ask(request)
420 if matches!(
421 hook_permission_override,
422 Some(crate::HookPermissionOverride::Allow)
423 ) && request.reason_code
424 != crate::permission::PermissionReasonCode::HardDangerous =>
425 {
426 continue;
427 }
428 crate::permission::PermissionOutcome::Ask(request) => request,
429 }
430 } else {
431 let force_ask =
434 permission_checker.requires_forced_confirmation(&tool_name, &args);
435 let hook_allows = matches!(
436 hook_permission_override,
437 Some(crate::HookPermissionOverride::Allow)
438 );
439 if (ctx.bypass_permissions || hook_allows) && !force_ask {
440 continue;
441 }
442 let decision = if force_ask {
443 permission_checker.check_or_request_forced(context).await
444 } else if let Some(session_id) = ctx.session_id {
445 permission_checker
446 .check_or_request_for_session(session_id, context)
447 .await
448 } else {
449 permission_checker.check_or_request(context).await
450 };
451 match decision {
452 Ok(true) => continue,
453 Ok(false) => {
454 return Err(ToolError::Execution(format!(
455 "Permission denied for: {}",
456 resource
457 )));
458 }
459 Err(PermissionError::ConfirmationRequired { .. }) => {
460 crate::permission::PermissionRequest {
461 request_id: call.id.clone(),
462 session_id: ctx.session_id.unwrap_or_default().to_string(),
463 workspace_path: None,
464 tool_name: tool_name.clone(),
465 permission_type,
466 resource: resource.clone(),
467 operation_summary: operation_summary.clone(),
468 risk_level,
469 reason_code: if force_ask {
470 crate::permission::PermissionReasonCode::ConfiguredAlwaysAsk
471 } else {
472 crate::permission::PermissionReasonCode::RiskThreshold
473 },
474 effective_mode: bamboo_config::settings::PermissionMode::Default,
475 bypass_requested: ctx.bypass_permissions,
476 policy_revision: 0,
477 matched_rule: None,
478 allowed_decisions:
479 crate::permission::PermissionRequest::forced_decisions(),
480 suggested_matchers: crate::permission::conservative_matchers(
481 permission_type,
482 &resource,
483 ),
484 }
485 }
486 Err(other) => return Err(permission_error_to_tool_error(other)),
487 }
488 };
489
490 if let Some(proxy) = proxy {
494 let approved = proxy
495 .request_approval(crate::approval::ApprovalAsk {
496 tool_name: tool_name.clone(),
497 permission: permission_type.description().to_string(),
498 resource: resource.clone(),
499 permission_request: Some(request.clone()),
500 })
501 .await;
502 if approved {
503 continue;
504 }
505 return Err(ToolError::Execution(format!(
506 "Permission denied by host for: {}",
507 resource
508 )));
509 }
510
511 if let Some(tx) = ctx.event_tx {
514 let _ = tx
515 .send(bamboo_agent_core::AgentEvent::ToolApprovalRequested {
516 tool_call_id: call.id.clone(),
517 tool_name: tool_name.clone(),
518 parameters: args.clone(),
519 })
520 .await;
521
522 let question = format!(
523 "**Permission required**\n\nThe `{}` tool needs approval to {} on:\n\n`{}`",
524 tool_name,
525 permission_type.description(),
526 resource
527 );
528 if let Some(config) = config {
529 config.register_pending_request(request.clone());
530 }
531 let payload = serde_json::json!({
532 "status": "awaiting_permission_approval",
533 "question": question,
534 "permission_type": permission_type,
535 "resource": resource,
536 "options": ["Approve", "Deny"],
537 "allow_custom": false,
538 "permission_request": request,
539 });
540 return Ok(Some(ToolOutcome::Completed(ToolResult {
541 success: true,
542 result: payload.to_string(),
543 display_preference: Some("request_permissions".to_string()),
544 images: Vec::new(),
545 })));
546 }
547
548 return Err(ToolError::Execution(format!(
549 "Permission approval required for: {}",
550 resource
551 )));
552 }
553 }
554
555 Ok(None)
556 }
557
558 fn list_tools(&self) -> Vec<ToolSchema> {
559 self.registry.list_tools()
560 }
561
562 fn tool_mutability(&self, tool_name: &str) -> crate::ToolMutability {
563 self.registry
564 .get(tool_name)
565 .map(|tool| tool.classify(&serde_json::Value::Null).mutability)
566 .unwrap_or_else(|| crate::classify_tool(tool_name))
567 }
568
569 fn call_mutability(&self, call: &ToolCall) -> crate::ToolMutability {
570 let canonical = resolve_registered_tool_name(&self.registry, call.function.name.trim());
571 let args = bamboo_agent_core::parse_tool_args_best_effort(&call.function.arguments).0;
572 self.registry
573 .get(&canonical)
574 .map(|tool| tool.classify(&args).mutability)
575 .unwrap_or_else(|| self.tool_mutability(&canonical))
576 }
577
578 fn tool_concurrency_safe(&self, tool_name: &str) -> bool {
579 let canonical = resolve_registered_tool_name(&self.registry, tool_name);
580 self.registry
581 .get(&canonical)
582 .map(|tool| tool.classify(&serde_json::Value::Null).parallel_safe)
583 .unwrap_or_else(|| self.tool_mutability(&canonical) == crate::ToolMutability::ReadOnly)
584 }
585
586 fn call_concurrency_safe(&self, call: &ToolCall) -> bool {
587 let canonical = resolve_registered_tool_name(&self.registry, call.function.name.trim());
588 let args = bamboo_agent_core::parse_tool_args_best_effort(&call.function.arguments).0;
589 self.registry
590 .get(&canonical)
591 .map(|tool| tool.classify(&args).parallel_safe)
592 .unwrap_or_else(|| self.tool_concurrency_safe(&canonical))
593 }
594
595 fn call_parallel_classification(&self, call: &ToolCall) -> (crate::ToolMutability, bool) {
596 let canonical = resolve_registered_tool_name(&self.registry, call.function.name.trim());
600 let args = bamboo_agent_core::parse_tool_args_best_effort(&call.function.arguments).0;
601 match self.registry.get(&canonical) {
602 Some(tool) => {
603 let class = tool.classify(&args);
604 (class.mutability, class.parallel_safe)
605 }
606 None => (
607 self.tool_mutability(&canonical),
608 self.tool_concurrency_safe(&canonical),
609 ),
610 }
611 }
612}
613
614pub struct BuiltinToolExecutorBuilder {
616 registry: ToolRegistry,
617 permission_checker: Option<Arc<dyn PermissionChecker>>,
618}
619
620impl BuiltinToolExecutorBuilder {
621 pub fn new() -> Self {
623 Self {
624 registry: ToolRegistry::new(),
625 permission_checker: None,
626 }
627 }
628
629 pub fn with_default_tools(self) -> Self {
631 BuiltinToolExecutor::register_builtin_tools(&self.registry, None);
632 self
633 }
634
635 pub fn with_filesystem_tool(self, name: &str) -> Result<Self, ToolError> {
637 match name {
638 "Read" => self.registry.register(ReadTool::new()),
639 "Write" => self.registry.register(WriteTool::new()),
640 "Edit" | "apply_patch" => self.registry.register(EditTool::new()),
642 "NotebookEdit" => self.registry.register(NotebookEditTool::new()),
643 _ => return Err(ToolError::NotFound(format!("Unknown tool: {}", name))),
644 }
645 .map_err(|e| ToolError::Execution(e.to_string()))?;
646 Ok(self)
647 }
648
649 pub fn with_command_tool(self, name: &str) -> Result<Self, ToolError> {
651 match name {
652 "Bash" => self.registry.register(BashTool::new()),
653 "BashOutput" => self.registry.register(BashOutputTool::new()),
654 "KillShell" => self.registry.register(KillShellTool::new()),
655 "Task" => self.registry.register(TaskTool::new()),
656 _ => return Err(ToolError::NotFound(format!("Unknown tool: {}", name))),
657 }
658 .map_err(|e| ToolError::Execution(e.to_string()))?;
659 Ok(self)
660 }
661
662 pub fn with_tool<T: Tool + 'static>(self, tool: T) -> Result<Self, ToolError> {
664 self.registry
665 .register(tool)
666 .map_err(|e| ToolError::Execution(e.to_string()))?;
667 Ok(self)
668 }
669
670 pub fn with_permission_checker(mut self, checker: Arc<dyn PermissionChecker>) -> Self {
672 self.permission_checker = Some(checker);
673 self
674 }
675
676 pub fn build(self) -> BuiltinToolExecutor {
678 BuiltinToolExecutor {
679 registry: self.registry,
680 permission_checker: self.permission_checker,
681 }
682 }
683}
684
685impl Default for BuiltinToolExecutorBuilder {
686 fn default() -> Self {
687 Self::new()
688 }
689}
690
691#[cfg(test)]
692mod tests {
693 use super::*;
694 use bamboo_agent_core::AgentEvent;
695 use bamboo_agent_core::FunctionCall;
696 use bamboo_agent_core::ToolCtx;
697 use bamboo_agent_core::ToolExecutionContext;
698 use bamboo_domain::tool_names::{normalize_tool_ref, BUILTIN_TOOL_NAMES};
699 use serde_json::json;
700 use std::sync::atomic::{AtomicUsize, Ordering};
701 use std::sync::Arc;
702 use tokio::fs;
703 use tokio::sync::mpsc;
704
705 use crate::tools::WriteTool;
706
707 fn make_tool_call(name: &str, args: serde_json::Value) -> ToolCall {
708 ToolCall {
709 id: "call_1".to_string(),
710 tool_type: "function".to_string(),
711 function: FunctionCall {
712 name: name.to_string(),
713 arguments: args.to_string(),
714 },
715 }
716 }
717
718 fn make_tool_call_with_raw_args(name: &str, raw_args: &str) -> ToolCall {
719 ToolCall {
720 id: "call_1".to_string(),
721 tool_type: "function".to_string(),
722 function: FunctionCall {
723 name: name.to_string(),
724 arguments: raw_args.to_string(),
725 },
726 }
727 }
728
729 fn make_executor(
730 permission_checker: Option<Arc<dyn PermissionChecker>>,
731 ) -> BuiltinToolExecutor {
732 let builder = BuiltinToolExecutorBuilder::new()
733 .with_tool(WriteTool::new())
734 .expect("register Write tool");
735
736 let builder = match permission_checker {
737 Some(checker) => builder.with_permission_checker(checker),
738 None => builder,
739 };
740
741 builder.build()
742 }
743
744 struct RecordingApprovalProxy {
745 requests: Arc<AtomicUsize>,
746 approve: bool,
747 }
748
749 #[async_trait]
750 impl crate::approval::ApprovalProxy for RecordingApprovalProxy {
751 async fn request_approval(&self, _ask: crate::approval::ApprovalAsk) -> bool {
752 self.requests.fetch_add(1, Ordering::SeqCst);
753 self.approve
754 }
755 }
756
757 #[test]
758 fn test_normalize_tool_ref_accepts_claude_style_names() {
759 assert_eq!(
760 normalize_tool_ref("default::Bash"),
761 Some("Bash".to_string())
762 );
763 }
764
765 #[test]
766 fn test_normalize_tool_ref_accepts_legacy_camel_aliases() {
767 assert_eq!(
768 normalize_tool_ref("default::fileExists"),
769 Some("FileExists".to_string())
770 );
771 assert_eq!(
772 normalize_tool_ref("default::getCurrentDir"),
773 Some("GetCurrentDir".to_string())
774 );
775 assert_eq!(
776 normalize_tool_ref("default::getFileInfo"),
777 Some("GetFileInfo".to_string())
778 );
779 assert_eq!(
780 normalize_tool_ref("default::setWorkspace"),
781 Some("SetWorkspace".to_string())
782 );
783 assert_eq!(
784 normalize_tool_ref("default::sleep"),
785 Some("Sleep".to_string())
786 );
787 }
788
789 #[test]
790 fn test_normalize_tool_ref_accepts_legacy_snake_case_aliases() {
791 assert_eq!(
792 normalize_tool_ref("default::execute_command"),
793 Some("Bash".to_string())
794 );
795 assert_eq!(
796 normalize_tool_ref("default::file_exists"),
797 Some("FileExists".to_string())
798 );
799 assert_eq!(
800 normalize_tool_ref("default::get_current_dir"),
801 Some("GetCurrentDir".to_string())
802 );
803 assert_eq!(
804 normalize_tool_ref("default::get_file_info"),
805 Some("GetFileInfo".to_string())
806 );
807 assert_eq!(
808 normalize_tool_ref("default::list_directory"),
809 Some("Glob".to_string())
810 );
811 assert_eq!(
812 normalize_tool_ref("default::memory_note"),
813 Some("memory_note".to_string())
814 );
815 assert_eq!(
816 normalize_tool_ref("default::read_file"),
817 Some("Read".to_string())
818 );
819 assert_eq!(
820 normalize_tool_ref("default::set_workspace"),
821 Some("SetWorkspace".to_string())
822 );
823 assert_eq!(
824 normalize_tool_ref("default::write_file"),
825 Some("Write".to_string())
826 );
827 }
828
829 #[test]
830 fn test_normalize_tool_ref_accepts_spawn_task_aliases() {
831 for alias in [
832 "default::spawn_session",
833 "default::sub_session",
834 "default::sub_task",
835 "default::team_agent",
836 "default::child_session",
837 ] {
838 assert_eq!(normalize_tool_ref(alias), Some("SubAgent".to_string()));
839 }
840 }
841
842 #[test]
843 fn test_normalize_tool_ref_accepts_server_overlay_tools() {
844 assert_eq!(normalize_tool_ref("compress_context"), None);
845 assert_eq!(
846 normalize_tool_ref("default::read_skill_resource"),
847 Some("read_skill_resource".to_string())
848 );
849 }
850
851 #[tokio::test]
852 async fn test_executor_accepts_legacy_read_file_path_argument() {
853 let dir = tempfile::tempdir().unwrap();
854 let file_path = dir.path().join("legacy-read.txt");
855 fs::write(&file_path, "legacy read content").await.unwrap();
856
857 let executor = BuiltinToolExecutor::new();
858 let call = make_tool_call("read_file", json!({"path": file_path}));
859
860 let result = executor.execute(&call).await.unwrap();
861 assert!(result.success);
862 assert!(result.result.contains("legacy read content"));
863 }
864
865 #[tokio::test]
866 async fn test_executor_accepts_legacy_list_directory_without_pattern() {
867 let dir = tempfile::tempdir().unwrap();
868 let file_path = dir.path().join("legacy-list.txt");
869 fs::write(&file_path, "legacy list content").await.unwrap();
870
871 let executor = BuiltinToolExecutor::new();
872 let call = make_tool_call("list_directory", json!({"path": dir.path()}));
873
874 let result = executor.execute(&call).await.unwrap();
875 assert!(result.success);
876 assert!(result.result.contains("legacy-list.txt"));
877 }
878
879 #[tokio::test]
880 async fn test_executor_accepts_canonical_read_with_path_argument() {
881 let dir = tempfile::tempdir().unwrap();
882 let file_path = dir.path().join("canonical-read.txt");
883 fs::write(&file_path, "canonical read content")
884 .await
885 .unwrap();
886
887 let executor = BuiltinToolExecutor::new();
888 let call = make_tool_call("Read", json!({"path": file_path}));
889
890 let result = executor.execute(&call).await.unwrap();
891 assert!(result.success);
892 assert!(result.result.contains("canonical read content"));
893 }
894
895 #[tokio::test]
896 async fn test_executor_accepts_canonical_glob_without_pattern_when_path_present() {
897 let dir = tempfile::tempdir().unwrap();
898 let file_path = dir.path().join("canonical-list.txt");
899 fs::write(&file_path, "canonical list content")
900 .await
901 .unwrap();
902
903 let executor = BuiltinToolExecutor::new();
904 let call = make_tool_call("Glob", json!({"path": dir.path()}));
905
906 let result = executor.execute(&call).await.unwrap();
907 assert!(result.success);
908 assert!(result.result.contains("canonical-list.txt"));
909 }
910
911 #[test]
912 fn test_executor_workspace_mutability_depends_on_path_argument() {
913 let executor = BuiltinToolExecutor::new();
914 let get_call = make_tool_call("Workspace", json!({}));
915 let set_call = make_tool_call("Workspace", json!({"path": "/tmp"}));
916
917 assert_eq!(
918 executor.call_mutability(&get_call),
919 crate::ToolMutability::ReadOnly
920 );
921 assert!(executor.call_concurrency_safe(&get_call));
922
923 assert_eq!(
924 executor.call_mutability(&set_call),
925 crate::ToolMutability::Mutating
926 );
927 assert!(!executor.call_concurrency_safe(&set_call));
928 }
929
930 #[test]
931 fn call_parallel_classification_matches_individual_methods() {
932 let executor = BuiltinToolExecutor::new();
940 let cases: &[(&str, serde_json::Value)] = &[
941 ("Read", json!({})),
942 ("Grep", json!({"pattern": "x"})),
943 (
944 "Write",
945 json!({"file_path": "/tmp/par_cls.txt", "content": "y"}),
946 ),
947 ("Bash", json!({"command": "echo hi"})),
948 ("Workspace", json!({})),
949 ("Workspace", json!({"path": "/tmp"})),
950 ];
951
952 for (name, args) in cases {
953 let call = make_tool_call(name, args.clone());
954 let expected_mutability = executor.call_mutability(&call);
955 let expected_concurrency = executor.call_concurrency_safe(&call);
956 let (mutability, concurrency) = executor.call_parallel_classification(&call);
957 assert_eq!(
958 mutability, expected_mutability,
959 "mutability mismatch for {name} ({args})"
960 );
961 assert_eq!(
962 concurrency, expected_concurrency,
963 "concurrency mismatch for {name} ({args})"
964 );
965 }
966 }
967
968 #[test]
969 fn list_tools_snapshot_is_stable_across_calls() {
970 let executor = BuiltinToolExecutor::new();
975 let first: Vec<String> = executor
976 .list_tools()
977 .into_iter()
978 .map(|s| s.function.name)
979 .collect();
980 let second: Vec<String> = executor
981 .list_tools()
982 .into_iter()
983 .map(|s| s.function.name)
984 .collect();
985 assert!(!first.is_empty(), "builtin executor should expose tools");
986 assert_eq!(
987 first, second,
988 "list_tools() must be deterministic per round"
989 );
990 }
991
992 #[tokio::test]
993 async fn test_executor_recovers_truncated_json_arguments() {
994 let dir = tempfile::tempdir().unwrap();
995 let path = dir.path().join("recovered-write.txt");
996
997 let malformed_args = format!(
999 r#"{{"file_path":"{}","content":"recovered content""#,
1000 path.display()
1001 );
1002
1003 let executor = BuiltinToolExecutor::new();
1004 let call = make_tool_call_with_raw_args("Write", &malformed_args);
1005
1006 let result = executor
1007 .execute(&call)
1008 .await
1009 .expect("truncated JSON should be auto-repaired");
1010 assert!(result.success);
1011
1012 let written = fs::read_to_string(&path)
1013 .await
1014 .expect("file should be written");
1015 assert_eq!(written, "recovered content");
1016 }
1017
1018 #[test]
1019 fn test_normalize_tool_ref_rejects_unknown_tool() {
1020 assert_eq!(normalize_tool_ref("default::search"), None);
1021 }
1022
1023 #[test]
1024 fn test_executor_does_not_expose_legacy_tools() {
1025 let executor = BuiltinToolExecutor::new();
1026 let tool_names: Vec<String> = executor
1027 .list_tools()
1028 .into_iter()
1029 .map(|schema| schema.function.name)
1030 .collect();
1031
1032 for legacy in ["claude_code", "search_in_file", "search_in_project"] {
1033 assert!(!tool_names.iter().any(|name| name == legacy));
1034 }
1035 }
1036
1037 #[test]
1038 fn test_critical_tool_schemas_match_claude_shapes() {
1039 let executor = BuiltinToolExecutor::new();
1040 let tools = executor.list_tools();
1041
1042 let get_params = |name: &str| {
1043 tools
1044 .iter()
1045 .find(|tool| tool.function.name == name)
1046 .unwrap()
1047 .function
1048 .parameters
1049 .clone()
1050 };
1051
1052 let grep = get_params("Grep");
1053 assert_eq!(grep["required"], json!(["pattern"]));
1054 assert_eq!(
1055 grep["properties"]["output_mode"]["enum"],
1056 json!(["content", "files_with_matches", "count"])
1057 );
1058 assert!(grep["properties"]["-A"].is_object());
1059 assert!(grep["properties"]["-B"].is_object());
1060 assert!(grep["properties"]["-C"].is_object());
1061 assert!(grep["properties"]["-n"].is_object());
1062 assert!(grep["properties"]["-i"].is_object());
1063
1064 let edit = get_params("Edit");
1065 assert_eq!(edit["required"], json!(["file_path"]));
1066 assert_eq!(edit["properties"]["old_string"]["type"], "string");
1067 assert_eq!(edit["properties"]["new_string"]["type"], "string");
1068 assert_eq!(edit["properties"]["patch"]["type"], "string");
1069 assert_eq!(edit["properties"]["replace_all"]["type"], "boolean");
1070 assert!(edit.get("oneOf").is_none());
1071
1072 assert_eq!(edit["properties"]["patch"]["type"], "string");
1075 assert_eq!(edit["properties"]["line_number"]["type"], "integer");
1076
1077 let bash = get_params("Bash");
1078 assert_eq!(bash["required"], json!(["command"]));
1079 assert_eq!(bash["properties"]["run_in_background"]["type"], "boolean");
1080 assert_eq!(bash["properties"]["workdir"]["type"], "string");
1081
1082 let bash_output = get_params("BashOutput");
1083 assert_eq!(bash_output["required"], json!(["bash_id"]));
1084 assert_eq!(bash_output["properties"]["filter"]["type"], "string");
1085 }
1086
1087 #[test]
1088 fn test_tool_schemas_avoid_openai_forbidden_top_level_keywords() {
1089 let executor = BuiltinToolExecutor::new();
1090 let tools = executor.list_tools();
1091 let forbidden = ["oneOf", "anyOf", "allOf", "not", "enum"];
1092
1093 for tool in tools {
1094 let params = &tool.function.parameters;
1095 assert_eq!(
1096 params["type"], "object",
1097 "tool '{}' parameters must be a top-level object schema",
1098 tool.function.name
1099 );
1100 for key in forbidden {
1101 assert!(
1102 params.get(key).is_none(),
1103 "tool '{}' parameters contains forbidden top-level keyword '{}'",
1104 tool.function.name,
1105 key
1106 );
1107 }
1108 }
1109 }
1110
1111 #[test]
1112 fn test_executor_has_all_builtin_tools() {
1113 let executor = BuiltinToolExecutor::new();
1114 let tools = executor.list_tools();
1115
1116 assert_eq!(tools.len(), BUILTIN_TOOL_NAMES.len());
1117
1118 let tool_names: Vec<String> = tools.iter().map(|t| t.function.name.clone()).collect();
1119 for tool_name in BUILTIN_TOOL_NAMES {
1120 assert!(tool_names.contains(&tool_name.to_string()));
1121 }
1122 }
1123
1124 #[test]
1125 fn test_executor_builds_enhanced_prompt() {
1126 let executor = BuiltinToolExecutor::new();
1127 let prompt = executor.build_enhanced_prompt(GuideBuildContext::default());
1128 assert!(prompt.contains("## Tool Usage Guidelines"));
1129 assert!(prompt.contains("**Read**"));
1130 }
1131
1132 #[test]
1133 fn test_executor_builder_empty() {
1134 let executor = BuiltinToolExecutorBuilder::new().build();
1135 assert!(executor.list_tools().is_empty());
1136 }
1137
1138 #[test]
1139 fn test_executor_builder_with_default_tools() {
1140 let executor = BuiltinToolExecutorBuilder::new()
1141 .with_default_tools()
1142 .build();
1143 assert_eq!(executor.list_tools().len(), BUILTIN_TOOL_NAMES.len());
1144 }
1145
1146 #[test]
1147 fn test_executor_builder_with_specific_tool() {
1148 let executor = BuiltinToolExecutorBuilder::new()
1149 .with_filesystem_tool("Read")
1150 .unwrap()
1151 .build();
1152
1153 let tools = executor.list_tools();
1154 assert_eq!(tools.len(), 1);
1155 assert_eq!(tools[0].function.name, "Read");
1156 }
1157
1158 #[tokio::test]
1159 async fn test_executor_skips_permission_checks_without_checker() {
1160 let executor = make_executor(None);
1161 let path = "/tmp/executor_permission_none.txt";
1162 let _ = fs::remove_file(path).await;
1163
1164 let call = make_tool_call("Write", json!({"file_path": path, "content": "ok"}));
1165 let result = executor.execute(&call).await.expect("execute tool");
1166
1167 assert!(result.success);
1168 let _ = fs::remove_file(path).await;
1169 }
1170
1171 #[tokio::test]
1172 async fn test_executor_with_permission_checker_enforces_checks() {
1173 let checker = Arc::new(crate::permission::DenyDangerousPermissionChecker);
1174 let executor = make_executor(Some(checker));
1175 let path = "/tmp/executor_permission_denied.txt";
1176 let _ = fs::remove_file(path).await;
1177
1178 let call = make_tool_call("Write", json!({"file_path": path, "content": "nope"}));
1179 let result = executor.execute(&call).await;
1180
1181 assert!(matches!(result, Err(ToolError::Execution(_))));
1182 assert!(fs::metadata(path).await.is_err());
1183 }
1184
1185 #[tokio::test]
1186 async fn test_bypass_permissions_skips_checker() {
1187 let config = Arc::new(crate::permission::PermissionConfig::new());
1193 let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
1194 let executor = BuiltinToolExecutorBuilder::new()
1195 .with_tool(BashTool::new())
1196 .expect("register Bash tool")
1197 .with_permission_checker(checker)
1198 .build();
1199 let dir = tempfile::tempdir().unwrap();
1200 let path = dir.path().join("bypass_allows_bash.txt");
1201 let command = format!("printf ordinary > {}", path.display());
1202 let approval_requests = Arc::new(AtomicUsize::new(0));
1203 let proxy: Arc<dyn crate::approval::ApprovalProxy> = Arc::new(RecordingApprovalProxy {
1204 requests: approval_requests.clone(),
1205 approve: true,
1206 });
1207 let (event_tx, mut event_rx) = mpsc::channel(8);
1208
1209 let call = make_tool_call("Bash", json!({"command": command}));
1210 let ctx = ToolExecutionContext {
1211 session_id: Some("s-bypass"),
1212 tool_call_id: &call.id,
1213 event_tx: Some(&event_tx),
1214 available_tool_schemas: None,
1215 bypass_permissions: true,
1216 can_async_resume: false,
1217 bash_completion_sink: None,
1218 pre_parsed_args: None,
1219 };
1220 let result = crate::approval::with_approval_proxy(
1221 Some(proxy),
1222 executor.execute_with_context(&call, ctx),
1223 )
1224 .await;
1225
1226 assert!(result.is_ok(), "bypass should allow the write: {result:?}");
1227 assert_eq!(fs::read_to_string(&path).await.unwrap(), "ordinary");
1228 assert_eq!(
1229 approval_requests.load(Ordering::SeqCst),
1230 0,
1231 "ordinary bypassed child command must not invoke the parent reviewer"
1232 );
1233 assert!(
1234 event_rx.try_recv().is_err(),
1235 "ordinary bypassed child command must not emit a human approval event"
1236 );
1237 }
1238
1239 #[tokio::test]
1240 async fn hook_allow_skips_configured_ask_for_exact_call() {
1241 let dir = tempfile::tempdir().unwrap();
1242 let path = dir.path().join("hook-allowed.txt");
1243 let path_str = path.to_str().unwrap().to_string();
1244 let config = Arc::new(crate::permission::PermissionConfig::new());
1245 config.set_ask_rules([format!("Write({}/**)", dir.path().to_str().unwrap())]);
1246 let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
1247 let executor = make_executor(Some(checker));
1248 let call = make_tool_call(
1249 "Write",
1250 json!({"file_path": path_str, "content": "allowed by hook"}),
1251 );
1252 let ctx = ToolExecutionContext {
1253 session_id: Some("s-hook-allow"),
1254 tool_call_id: &call.id,
1255 event_tx: None,
1256 available_tool_schemas: None,
1257 bypass_permissions: false,
1258 can_async_resume: false,
1259 bash_completion_sink: None,
1260 pre_parsed_args: None,
1261 };
1262
1263 let result = crate::with_hook_permission_override(
1264 Some(crate::HookPermissionOverride::Allow),
1265 &call.id,
1266 executor.execute_with_context(&call, ctx),
1267 )
1268 .await;
1269
1270 assert!(
1271 result.is_ok(),
1272 "hook allow should skip ordinary ask: {result:?}"
1273 );
1274 assert_eq!(fs::read_to_string(path).await.unwrap(), "allowed by hook");
1275 assert_eq!(
1276 crate::current_hook_permission_override(&call.id),
1277 None,
1278 "the one-call override must not leak"
1279 );
1280 }
1281
1282 #[tokio::test]
1283 async fn hook_allow_cannot_skip_hard_dangerous_parent_review() {
1284 let config = Arc::new(crate::permission::PermissionConfig::new());
1285 let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
1286 let executor = BuiltinToolExecutorBuilder::new()
1287 .with_tool(BashTool::new())
1288 .expect("register Bash tool")
1289 .with_permission_checker(checker)
1290 .build();
1291 let dir = tempfile::tempdir().unwrap();
1292 let path = dir.path().join("hard-dangerous-must-not-run.txt");
1293 let command = format!("eval 'printf denied > {}'", path.display());
1294 let requests = Arc::new(AtomicUsize::new(0));
1295 let proxy: Arc<dyn crate::approval::ApprovalProxy> = Arc::new(RecordingApprovalProxy {
1296 requests: requests.clone(),
1297 approve: false,
1298 });
1299 let call = make_tool_call("Bash", json!({"command": command}));
1300 let ctx = ToolExecutionContext {
1301 session_id: Some("s-hook-hard-dangerous"),
1302 tool_call_id: &call.id,
1303 event_tx: None,
1304 available_tool_schemas: None,
1305 bypass_permissions: true,
1306 can_async_resume: false,
1307 bash_completion_sink: None,
1308 pre_parsed_args: None,
1309 };
1310
1311 let result = crate::with_hook_permission_override(
1312 Some(crate::HookPermissionOverride::Allow),
1313 &call.id,
1314 crate::approval::with_approval_proxy(
1315 Some(proxy),
1316 executor.execute_with_context(&call, ctx),
1317 ),
1318 )
1319 .await;
1320
1321 assert!(
1322 matches!(result, Err(ToolError::Execution(ref message)) if message.contains("denied by host")),
1323 "hard-dangerous review must remain authoritative: {result:?}"
1324 );
1325 assert_eq!(requests.load(Ordering::SeqCst), 1);
1326 assert!(!path.exists());
1327 }
1328
1329 #[tokio::test]
1330 async fn hook_allow_cannot_skip_explicit_deny() {
1331 let dir = tempfile::tempdir().unwrap();
1332 let path = dir.path().join("explicit-deny.txt");
1333 let path_str = path.to_str().unwrap().to_string();
1334 let config = Arc::new(crate::permission::PermissionConfig::new());
1335 config.deny_scoped_session_permission(
1336 "s-hook-explicit-deny",
1337 crate::permission::PermissionType::WriteFile,
1338 path_str.clone(),
1339 );
1340 let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
1341 let executor = make_executor(Some(checker));
1342 let call = make_tool_call(
1343 "Write",
1344 json!({"file_path": path_str, "content": "must not be written"}),
1345 );
1346 let ctx = ToolExecutionContext {
1347 session_id: Some("s-hook-explicit-deny"),
1348 tool_call_id: &call.id,
1349 event_tx: None,
1350 available_tool_schemas: None,
1351 bypass_permissions: false,
1352 can_async_resume: false,
1353 bash_completion_sink: None,
1354 pre_parsed_args: None,
1355 };
1356
1357 let result = crate::with_hook_permission_override(
1358 Some(crate::HookPermissionOverride::Allow),
1359 &call.id,
1360 executor.execute_with_context(&call, ctx),
1361 )
1362 .await;
1363
1364 assert!(
1365 matches!(result, Err(ToolError::Execution(ref message)) if message.contains("remembered session decision")),
1366 "explicit deny must remain authoritative: {result:?}"
1367 );
1368 assert!(!path.exists());
1369 }
1370
1371 #[tokio::test]
1372 async fn test_forced_ask_rule_overrides_bypass() {
1373 let config = Arc::new(crate::permission::PermissionConfig::new());
1377 let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
1378 let executor = BuiltinToolExecutorBuilder::new()
1379 .with_tool(BashTool::new())
1380 .expect("register Bash tool")
1381 .with_permission_checker(checker)
1382 .build();
1383 let dir = tempfile::tempdir().unwrap();
1384 let denied_path = dir.path().join("forced-denied.txt");
1385 let denied_command = format!("eval 'printf denied > {}'", denied_path.display());
1386 let denied_requests = Arc::new(AtomicUsize::new(0));
1387 let deny_proxy: Arc<dyn crate::approval::ApprovalProxy> =
1388 Arc::new(RecordingApprovalProxy {
1389 requests: denied_requests.clone(),
1390 approve: false,
1391 });
1392
1393 let denied_call = make_tool_call("Bash", json!({"command": denied_command}));
1394 let denied_ctx = ToolExecutionContext {
1395 session_id: Some("s-forced"),
1396 tool_call_id: &denied_call.id,
1397 event_tx: None,
1398 available_tool_schemas: None,
1399 bypass_permissions: true,
1400 can_async_resume: false,
1401 bash_completion_sink: None,
1402 pre_parsed_args: None,
1403 };
1404 let denied = crate::approval::with_approval_proxy(
1405 Some(deny_proxy),
1406 executor.execute_with_context(&denied_call, denied_ctx),
1407 )
1408 .await;
1409
1410 assert!(
1411 matches!(denied, Err(ToolError::Execution(ref message)) if message.contains("denied by host")),
1412 "parent denial must block forced-ask execution under bypass: {denied:?}"
1413 );
1414 assert_eq!(denied_requests.load(Ordering::SeqCst), 1);
1415 assert!(!denied_path.exists(), "denied command must not execute");
1416
1417 let approved_path = dir.path().join("forced-approved.txt");
1418 let approved_command = format!("eval 'printf approved > {}'", approved_path.display());
1419 let approved_requests = Arc::new(AtomicUsize::new(0));
1420 let approve_proxy: Arc<dyn crate::approval::ApprovalProxy> =
1421 Arc::new(RecordingApprovalProxy {
1422 requests: approved_requests.clone(),
1423 approve: true,
1424 });
1425 let approved_call = make_tool_call("Bash", json!({"command": approved_command}));
1426 let approved_ctx = ToolExecutionContext {
1427 session_id: Some("s-forced"),
1428 tool_call_id: &approved_call.id,
1429 event_tx: None,
1430 available_tool_schemas: None,
1431 bypass_permissions: true,
1432 can_async_resume: false,
1433 bash_completion_sink: None,
1434 pre_parsed_args: None,
1435 };
1436 let approved = crate::approval::with_approval_proxy(
1437 Some(approve_proxy),
1438 executor.execute_with_context(&approved_call, approved_ctx),
1439 )
1440 .await;
1441
1442 assert!(
1443 approved.is_ok(),
1444 "parent approval must allow forced-ask execution under bypass: {approved:?}"
1445 );
1446 assert_eq!(approved_requests.load(Ordering::SeqCst), 1);
1447 assert_eq!(fs::read_to_string(approved_path).await.unwrap(), "approved");
1448 }
1449
1450 #[tokio::test]
1451 async fn test_explicit_deny_overrides_bypass() {
1452 let dir = tempfile::tempdir().unwrap();
1453 let path = dir.path().join("explicit-deny.txt");
1454 let path_str = path.to_str().unwrap();
1455 let config = Arc::new(crate::permission::PermissionConfig::new());
1456 config.add_rule(crate::permission::PermissionRule::new(
1457 crate::permission::PermissionType::WriteFile,
1458 path_str,
1459 false,
1460 ));
1461 let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
1462 let executor = make_executor(Some(checker));
1463 let call = make_tool_call(
1464 "Write",
1465 json!({"file_path": path_str, "content": "blocked"}),
1466 );
1467 let ctx = ToolExecutionContext {
1468 session_id: Some("s-explicit-deny"),
1469 tool_call_id: &call.id,
1470 event_tx: None,
1471 available_tool_schemas: None,
1472 bypass_permissions: true,
1473 can_async_resume: false,
1474 bash_completion_sink: None,
1475 pre_parsed_args: None,
1476 };
1477
1478 let result = executor.execute_with_context(&call, ctx).await;
1479 assert!(
1480 matches!(result, Err(ToolError::Execution(ref message)) if message.contains("explicit policy")),
1481 "explicit deny must beat bypass: {result:?}"
1482 );
1483 assert!(!path.exists());
1484 }
1485
1486 #[tokio::test]
1487 async fn test_explicit_delete_deny_overrides_bypass() {
1488 let config = Arc::new(crate::permission::PermissionConfig::new());
1489 config.add_rule(crate::permission::PermissionRule::new(
1490 crate::permission::PermissionType::DeleteOperation,
1491 "rm child-to-preserve",
1492 false,
1493 ));
1494 let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
1495 let executor = BuiltinToolExecutorBuilder::new()
1496 .with_tool(BashTool::new())
1497 .expect("register Bash tool")
1498 .with_permission_checker(checker)
1499 .build();
1500 let call = make_tool_call("Bash", json!({"command": "rm child-to-preserve"}));
1501 let ctx = ToolExecutionContext {
1502 session_id: Some("s-explicit-delete-deny"),
1503 tool_call_id: &call.id,
1504 event_tx: None,
1505 available_tool_schemas: None,
1506 bypass_permissions: true,
1507 can_async_resume: false,
1508 bash_completion_sink: None,
1509 pre_parsed_args: None,
1510 };
1511
1512 let result = executor.execute_with_context(&call, ctx).await;
1513 assert!(
1514 matches!(result, Err(ToolError::Execution(ref message)) if message.contains("explicit policy")),
1515 "explicit delete deny must beat bypass: {result:?}"
1516 );
1517 }
1518
1519 #[tokio::test]
1520 async fn interactive_gate_returns_synthesized_approval_pause() {
1521 let config = Arc::new(crate::permission::PermissionConfig::new());
1529 config.set_ask_rules(["Write(/etc/**)".to_string()]);
1530 config.register_session_workspace("s-interactive", "/workspace/project");
1531 let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
1532 let executor = make_executor(Some(checker));
1533
1534 let (tx, mut rx) = mpsc::channel(8);
1535 let call = make_tool_call(
1536 "Write",
1537 json!({"file_path": "/etc/gated.conf", "content": "x"}),
1538 );
1539 let ctx = ToolExecutionContext {
1540 session_id: Some("s-interactive"),
1541 tool_call_id: &call.id,
1542 event_tx: Some(&tx),
1543 available_tool_schemas: None,
1544 bypass_permissions: false,
1545 can_async_resume: false,
1546 bash_completion_sink: None,
1547 pre_parsed_args: None,
1548 };
1549
1550 let result = executor
1551 .execute_with_context(&call, ctx)
1552 .await
1553 .expect("interactive gate should pause (Ok), not error");
1554
1555 assert_eq!(
1556 result.display_preference.as_deref(),
1557 Some("request_permissions"),
1558 "interactive gate must return the request_permissions pause result"
1559 );
1560 assert!(result.result.contains("awaiting_permission_approval"));
1561 let payload: serde_json::Value = serde_json::from_str(&result.result).expect("payload");
1562 let request = &payload["permission_request"];
1563 assert_eq!(request["request_id"], call.id);
1564 assert_eq!(request["session_id"], "s-interactive");
1565 assert_eq!(request["workspace_path"], "/workspace/project");
1566 assert_eq!(request["reason_code"], "configured_always_ask");
1567 assert_eq!(
1568 request["allowed_decisions"],
1569 json!(["allow_once", "deny_once"])
1570 );
1571 assert_eq!(payload["options"], json!(["Approve", "Deny"]));
1572 assert!(fs::metadata("/etc/gated.conf").await.is_err());
1573
1574 let ev = rx.recv().await.expect("approval event should be emitted");
1575 assert!(
1576 matches!(ev, AgentEvent::ToolApprovalRequested { tool_name, .. } if tool_name == "Write")
1577 );
1578 }
1579
1580 #[tokio::test]
1581 async fn check_permissions_for_returns_none_when_permitted() {
1582 let executor = make_executor(None);
1585 let call = make_tool_call("Read", json!({"file_path": "/tmp/whatever"}));
1586 let ctx = ToolExecutionContext::none(&call.id);
1587 let decision = executor
1588 .check_permissions_for(&call, &ctx)
1589 .await
1590 .expect("no checker means no gate");
1591 assert!(decision.is_none(), "no checker must yield Ok(None)");
1592 }
1593
1594 struct HostStub {
1597 approve: bool,
1598 }
1599
1600 #[async_trait]
1601 impl crate::approval::ApprovalProxy for HostStub {
1602 async fn request_approval(&self, _ask: crate::approval::ApprovalAsk) -> bool {
1603 self.approve
1604 }
1605 }
1606
1607 #[tokio::test]
1608 async fn approval_proxy_grant_lets_gated_tool_proceed() {
1609 let dir = tempfile::tempdir().unwrap();
1614 let path = dir.path().join("approved.txt");
1615 let path_str = path.to_str().unwrap().to_string();
1616 let config = Arc::new(crate::permission::PermissionConfig::new());
1617 config.set_ask_rules([format!("Write({}/**)", dir.path().to_str().unwrap())]);
1618 let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
1619 let executor = make_executor(Some(checker));
1620
1621 let call = make_tool_call("Write", json!({"file_path": path_str, "content": "ok"}));
1622 let ctx = ToolExecutionContext {
1623 session_id: Some("s-worker"),
1624 tool_call_id: &call.id,
1625 event_tx: None,
1626 available_tool_schemas: None,
1627 bypass_permissions: false,
1628 can_async_resume: false,
1629 bash_completion_sink: None,
1630 pre_parsed_args: None,
1631 };
1632
1633 let proxy: Arc<dyn crate::approval::ApprovalProxy> = Arc::new(HostStub { approve: true });
1634 let result = crate::approval::with_approval_proxy(
1635 Some(proxy),
1636 executor.execute_with_context(&call, ctx),
1637 )
1638 .await;
1639
1640 assert!(
1641 result.is_ok(),
1642 "host grant should let the write through: {result:?}"
1643 );
1644 assert_eq!(fs::read_to_string(&path).await.unwrap(), "ok");
1645 }
1646
1647 #[tokio::test]
1648 async fn approval_proxy_deny_fails_gated_tool_closed() {
1649 let dir = tempfile::tempdir().unwrap();
1652 let path = dir.path().join("denied.txt");
1653 let path_str = path.to_str().unwrap().to_string();
1654 let config = Arc::new(crate::permission::PermissionConfig::new());
1655 config.set_ask_rules([format!("Write({}/**)", dir.path().to_str().unwrap())]);
1656 let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
1657 let executor = make_executor(Some(checker));
1658
1659 let call = make_tool_call("Write", json!({"file_path": path_str, "content": "nope"}));
1660 let ctx = ToolExecutionContext {
1661 session_id: Some("s-worker"),
1662 tool_call_id: &call.id,
1663 event_tx: None,
1664 available_tool_schemas: None,
1665 bypass_permissions: false,
1666 can_async_resume: false,
1667 bash_completion_sink: None,
1668 pre_parsed_args: None,
1669 };
1670
1671 let proxy: Arc<dyn crate::approval::ApprovalProxy> = Arc::new(HostStub { approve: false });
1672 let result = crate::approval::with_approval_proxy(
1673 Some(proxy),
1674 executor.execute_with_context(&call, ctx),
1675 )
1676 .await;
1677
1678 assert!(
1679 matches!(result, Err(ToolError::Execution(ref m)) if m.contains("denied by host")),
1680 "host deny should fail the tool closed: {result:?}"
1681 );
1682 assert!(fs::metadata(&path).await.is_err());
1683 }
1684
1685 #[tokio::test]
1686 async fn tool_can_stream_events_via_execute_with_context() {
1687 struct StreamingTool;
1688
1689 #[async_trait]
1690 impl Tool for StreamingTool {
1691 fn name(&self) -> &str {
1692 "streaming_tool"
1693 }
1694
1695 fn description(&self) -> &str {
1696 "streams one token"
1697 }
1698
1699 fn parameters_schema(&self) -> serde_json::Value {
1700 json!({"type":"object","properties":{}})
1701 }
1702
1703 async fn invoke(
1704 &self,
1705 _args: serde_json::Value,
1706 ctx: ToolCtx,
1707 ) -> Result<ToolOutcome, ToolError> {
1708 ctx.emit(AgentEvent::Token {
1709 content: "stream".to_string(),
1710 })
1711 .await;
1712 Ok(ToolOutcome::Completed(ToolResult {
1713 success: true,
1714 result: "ok".to_string(),
1715 display_preference: None,
1716 images: Vec::new(),
1717 }))
1718 }
1719 }
1720
1721 let executor = BuiltinToolExecutor::new();
1722 executor
1723 .register_tool(StreamingTool)
1724 .expect("register streaming tool");
1725
1726 let (tx, mut rx) = mpsc::channel(8);
1727 let call = make_tool_call("streaming_tool", json!({}));
1728
1729 let result = executor
1730 .execute_with_context(
1731 &call,
1732 ToolExecutionContext {
1733 session_id: Some("s1"),
1734 tool_call_id: &call.id,
1735 event_tx: Some(&tx),
1736 available_tool_schemas: None,
1737 bypass_permissions: false,
1738 can_async_resume: false,
1739 bash_completion_sink: None,
1740 pre_parsed_args: None,
1741 },
1742 )
1743 .await
1744 .expect("execute tool");
1745
1746 assert!(result.success);
1747 assert_eq!(result.result, "ok");
1748
1749 let ev = rx.recv().await.expect("expected streamed event");
1750 assert!(
1751 matches!(ev, AgentEvent::ToolToken { tool_call_id, content } if tool_call_id == "call_1" && content == "stream")
1752 );
1753 }
1754
1755 #[tokio::test]
1756 async fn removed_legacy_tools_return_not_found() {
1757 let executor = BuiltinToolExecutor::new();
1758
1759 for legacy in ["claude_code", "search_in_file", "search_in_project"] {
1760 let call = make_tool_call(legacy, json!({}));
1761 let result = executor.execute(&call).await;
1762 assert!(matches!(result, Err(ToolError::NotFound(_))));
1763 }
1764 }
1765
1766 #[tokio::test]
1767 async fn executor_prefers_exact_tool_name_before_builtin_alias() {
1768 struct CustomSpawnSessionTool;
1769
1770 #[async_trait]
1771 impl Tool for CustomSpawnSessionTool {
1772 fn name(&self) -> &str {
1773 "spawn_session"
1774 }
1775
1776 fn description(&self) -> &str {
1777 "custom tool for regression coverage"
1778 }
1779
1780 fn parameters_schema(&self) -> serde_json::Value {
1781 json!({"type":"object","properties":{}})
1782 }
1783
1784 async fn invoke(
1785 &self,
1786 _args: serde_json::Value,
1787 _ctx: ToolCtx,
1788 ) -> Result<ToolOutcome, ToolError> {
1789 Ok(ToolOutcome::Completed(ToolResult {
1790 success: true,
1791 result: "custom-spawn-session".to_string(),
1792 display_preference: None,
1793 images: Vec::new(),
1794 }))
1795 }
1796 }
1797
1798 let executor = BuiltinToolExecutorBuilder::new()
1799 .with_tool(CustomSpawnSessionTool)
1800 .expect("register custom spawn_session tool")
1801 .build();
1802
1803 let call = make_tool_call("spawn_session", json!({}));
1804 let result = executor.execute(&call).await.expect("execute custom tool");
1805 assert!(result.success);
1806 assert_eq!(result.result, "custom-spawn-session");
1807 }
1808
1809 struct EchoArgsTool;
1814
1815 #[async_trait]
1816 impl Tool for EchoArgsTool {
1817 fn name(&self) -> &str {
1818 "echo_args"
1819 }
1820 fn description(&self) -> &str {
1821 "echoes the `v` arg"
1822 }
1823 fn parameters_schema(&self) -> serde_json::Value {
1824 json!({"type":"object","properties":{"v":{"type":"string"}}})
1825 }
1826 async fn invoke(
1827 &self,
1828 args: serde_json::Value,
1829 _ctx: ToolCtx,
1830 ) -> Result<ToolOutcome, ToolError> {
1831 let v = args
1832 .get("v")
1833 .and_then(serde_json::Value::as_str)
1834 .unwrap_or("<none>")
1835 .to_string();
1836 Ok(ToolOutcome::Completed(ToolResult {
1837 success: true,
1838 result: v,
1839 display_preference: None,
1840 images: Vec::new(),
1841 }))
1842 }
1843 }
1844
1845 fn ctx_with_pre_parsed<'a>(
1846 call_id: &'a str,
1847 pre_parsed: Option<&'a serde_json::Value>,
1848 ) -> ToolExecutionContext<'a> {
1849 ToolExecutionContext {
1850 session_id: Some("s-106"),
1851 tool_call_id: call_id,
1852 event_tx: None,
1853 available_tool_schemas: None,
1854 bypass_permissions: false,
1855 can_async_resume: false,
1856 bash_completion_sink: None,
1857 pre_parsed_args: pre_parsed,
1858 }
1859 }
1860
1861 #[tokio::test]
1862 async fn execute_with_context_reuses_pre_parsed_args_without_reparsing() {
1863 let executor = BuiltinToolExecutor::new();
1869 executor.register_tool(EchoArgsTool).expect("register echo");
1870
1871 let call = make_tool_call("echo_args", json!({"v": "raw"}));
1872 let pre_parsed = json!({"v": "preparsed"});
1873 let ctx = ctx_with_pre_parsed(&call.id, Some(&pre_parsed));
1874
1875 let result = executor
1876 .execute_with_context(&call, ctx)
1877 .await
1878 .expect("execute echo tool");
1879 assert_eq!(
1880 result.result, "preparsed",
1881 "executor must reuse pre_parsed_args, not re-parse the raw string"
1882 );
1883 }
1884
1885 #[tokio::test]
1886 async fn execute_with_context_parses_raw_when_no_pre_parsed_args() {
1887 let executor = BuiltinToolExecutor::new();
1891 executor.register_tool(EchoArgsTool).expect("register echo");
1892
1893 let call = make_tool_call("echo_args", json!({"v": "raw"}));
1894 let ctx = ctx_with_pre_parsed(&call.id, None);
1895
1896 let result = executor
1897 .execute_with_context(&call, ctx)
1898 .await
1899 .expect("execute echo tool");
1900 assert_eq!(
1901 result.result, "raw",
1902 "without pre_parsed_args the executor parses the raw string as before"
1903 );
1904 }
1905
1906 #[tokio::test]
1907 async fn execute_with_context_malformed_args_repair_unchanged_without_pre_parsed() {
1908 let dir = tempfile::tempdir().unwrap();
1912 let path = dir.path().join("recovered-no-preparsed.txt");
1913 let malformed_args = format!(
1914 r#"{{"file_path":"{}","content":"recovered content""#,
1915 path.display()
1916 );
1917
1918 let executor = BuiltinToolExecutor::new();
1919 let call = make_tool_call_with_raw_args("Write", &malformed_args);
1920 let ctx = ctx_with_pre_parsed(&call.id, None);
1921
1922 let result = executor
1923 .execute_with_context(&call, ctx)
1924 .await
1925 .expect("truncated JSON should be auto-repaired");
1926 assert!(result.success);
1927 let written = fs::read_to_string(&path).await.expect("file written");
1928 assert_eq!(written, "recovered content");
1929 }
1930}