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