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 raw_tool_name = normalize_tool_name(&call.function.name);
369 let tool_name = resolve_registered_tool_name(&self.registry, raw_tool_name);
370 if ctx.auto_approve_permissions && tool_name.eq_ignore_ascii_case("request_permissions") {
371 return Err(ToolError::Execution(
372 "Auto mode cannot request expanded permissions; operate within existing hard boundaries"
373 .to_string(),
374 ));
375 }
376 if ctx.plan_read_only && !crate::orchestrator::plan_mode_allows_tool(&tool_name) {
377 return Err(ToolError::Execution(format!(
378 "Plan mode: {tool_name} operation blocked"
379 )));
380 }
381 let Some(permission_checker) = &self.permission_checker else {
382 return Ok(None);
383 };
384 let hook_permission_override = crate::current_hook_permission_override(&call.id);
385
386 let mut args = if let Some(pre_parsed) = ctx.pre_parsed_args {
391 pre_parsed.clone()
392 } else {
393 parse_tool_args_best_effort(&call.function.arguments).0
394 };
395 if let Some(args_obj) = args.as_object_mut() {
396 normalize_legacy_builtin_args(raw_tool_name, args_obj);
397 }
398
399 if let Some(contexts) =
400 check_permissions(&tool_name, &args).map_err(permission_error_to_tool_error)?
401 {
402 for context in contexts {
403 let resource = context.resource.clone();
404 let operation_summary = context.operation_description.clone();
405 let risk_level = context.risk_level();
406 let permission_type = context.permission_type;
407 let platform_hard_deny = permission_checker.hard_deny_reason(&context);
408 let config = permission_checker.permission_config();
409 let proxy = crate::approval::current_approval_proxy();
410 let request = if let Some(config) = config.as_ref() {
411 let supported_decisions = if proxy.is_some() {
415 crate::permission::PermissionRequest::forced_decisions()
416 } else {
417 crate::permission::PermissionRequest::ordinary_decisions(true)
418 };
419 let workspace_path = args
420 .get("workspace_path")
421 .or_else(|| args.get("cwd"))
422 .and_then(|value| value.as_str())
423 .map(ToOwned::to_owned)
424 .or_else(|| {
425 ctx.session_id
426 .and_then(|session_id| config.session_workspace(session_id))
427 });
428 match config.evaluate(crate::permission::PermissionEvaluation {
429 request_id: call.id.clone(),
430 session_id: ctx.session_id.unwrap_or_default().to_string(),
431 workspace_path,
432 tool_name: tool_name.clone(),
433 tool_args: args.clone(),
434 permission_type,
435 resource: resource.clone(),
436 operation_summary: operation_summary.clone(),
437 risk_level,
438 bypass_requested: ctx.bypass_permissions,
439 auto_approve_requested: ctx.auto_approve_permissions,
440 platform_hard_deny,
441 consume_once: true,
442 supported_decisions,
443 }) {
444 crate::permission::PermissionOutcome::Allow { .. } => continue,
445 crate::permission::PermissionOutcome::Deny { reason, .. } => {
446 return Err(ToolError::Execution(reason.message));
447 }
448 crate::permission::PermissionOutcome::Ask(request)
449 if matches!(
450 hook_permission_override,
451 Some(crate::HookPermissionOverride::Allow)
452 ) && request.reason_code
453 != crate::permission::PermissionReasonCode::HardDangerous =>
454 {
455 continue;
456 }
457 crate::permission::PermissionOutcome::Ask(request) => request,
458 }
459 } else {
460 if let Some(reason) = platform_hard_deny {
463 return Err(ToolError::Execution(reason));
464 }
465 let force_ask =
466 permission_checker.requires_forced_confirmation(&tool_name, &args);
467 let hook_allows = matches!(
468 hook_permission_override,
469 Some(crate::HookPermissionOverride::Allow)
470 );
471 if ctx.auto_approve_permissions
472 || ((ctx.bypass_permissions || hook_allows) && !force_ask)
473 {
474 continue;
475 }
476 let decision = if force_ask {
477 permission_checker.check_or_request_forced(context).await
478 } else if let Some(session_id) = ctx.session_id {
479 permission_checker
480 .check_or_request_for_session(session_id, context)
481 .await
482 } else {
483 permission_checker.check_or_request(context).await
484 };
485 match decision {
486 Ok(true) => continue,
487 Ok(false) => {
488 return Err(ToolError::Execution(format!(
489 "Permission denied for: {}",
490 resource
491 )));
492 }
493 Err(PermissionError::ConfirmationRequired { .. }) => {
494 crate::permission::PermissionRequest {
495 request_id: call.id.clone(),
496 session_id: ctx.session_id.unwrap_or_default().to_string(),
497 workspace_path: None,
498 tool_name: tool_name.clone(),
499 permission_type,
500 resource: resource.clone(),
501 operation_summary: operation_summary.clone(),
502 risk_level,
503 reason_code: if force_ask {
504 crate::permission::PermissionReasonCode::ConfiguredAlwaysAsk
505 } else {
506 crate::permission::PermissionReasonCode::RiskThreshold
507 },
508 effective_mode: bamboo_config::settings::PermissionMode::Default,
509 bypass_requested: ctx.bypass_permissions,
510 auto_approve_requested: ctx.auto_approve_permissions,
511 policy_revision: 0,
512 matched_rule: None,
513 allowed_decisions:
514 crate::permission::PermissionRequest::forced_decisions(),
515 suggested_matchers: crate::permission::conservative_matchers(
516 permission_type,
517 &resource,
518 ),
519 }
520 }
521 Err(other) => return Err(permission_error_to_tool_error(other)),
522 }
523 };
524
525 if let Some(proxy) = proxy {
529 let approved = proxy
530 .request_approval(crate::approval::ApprovalAsk {
531 tool_name: tool_name.clone(),
532 permission: permission_type.description().to_string(),
533 resource: resource.clone(),
534 permission_request: Some(request.clone()),
535 })
536 .await;
537 if approved {
538 continue;
539 }
540 return Err(ToolError::Execution(format!(
541 "Permission denied by host for: {}",
542 resource
543 )));
544 }
545
546 if let Some(tx) = ctx.event_tx {
549 let _ = tx
550 .send(bamboo_agent_core::AgentEvent::ToolApprovalRequested {
551 tool_call_id: call.id.clone(),
552 tool_name: tool_name.clone(),
553 parameters: args.clone(),
554 })
555 .await;
556
557 let question = format!(
558 "**Permission required**\n\nThe `{}` tool needs approval to {} on:\n\n`{}`",
559 tool_name,
560 permission_type.description(),
561 resource
562 );
563 if let Some(config) = config {
564 config.register_pending_request(request.clone());
565 }
566 let payload = serde_json::json!({
567 "status": "awaiting_permission_approval",
568 "question": question,
569 "permission_type": permission_type,
570 "resource": resource,
571 "options": ["Approve", "Deny"],
572 "allow_custom": false,
573 "permission_request": request,
574 });
575 return Ok(Some(ToolOutcome::Completed(ToolResult {
576 success: true,
577 result: payload.to_string(),
578 display_preference: Some("request_permissions".to_string()),
579 images: Vec::new(),
580 })));
581 }
582
583 return Err(ToolError::Execution(format!(
584 "Permission approval required for: {}",
585 resource
586 )));
587 }
588 }
589
590 Ok(None)
591 }
592
593 fn list_tools(&self) -> Vec<ToolSchema> {
594 self.registry.list_tools()
595 }
596
597 fn tool_mutability(&self, tool_name: &str) -> crate::ToolMutability {
598 self.registry
599 .get(tool_name)
600 .map(|tool| tool.classify(&serde_json::Value::Null).mutability)
601 .unwrap_or_else(|| crate::classify_tool(tool_name))
602 }
603
604 fn call_mutability(&self, call: &ToolCall) -> crate::ToolMutability {
605 let canonical = resolve_registered_tool_name(&self.registry, call.function.name.trim());
606 let args = bamboo_agent_core::parse_tool_args_best_effort(&call.function.arguments).0;
607 self.registry
608 .get(&canonical)
609 .map(|tool| tool.classify(&args).mutability)
610 .unwrap_or_else(|| self.tool_mutability(&canonical))
611 }
612
613 fn tool_concurrency_safe(&self, tool_name: &str) -> bool {
614 let canonical = resolve_registered_tool_name(&self.registry, tool_name);
615 self.registry
616 .get(&canonical)
617 .map(|tool| tool.classify(&serde_json::Value::Null).parallel_safe)
618 .unwrap_or_else(|| self.tool_mutability(&canonical) == crate::ToolMutability::ReadOnly)
619 }
620
621 fn call_concurrency_safe(&self, call: &ToolCall) -> bool {
622 let canonical = resolve_registered_tool_name(&self.registry, call.function.name.trim());
623 let args = bamboo_agent_core::parse_tool_args_best_effort(&call.function.arguments).0;
624 self.registry
625 .get(&canonical)
626 .map(|tool| tool.classify(&args).parallel_safe)
627 .unwrap_or_else(|| self.tool_concurrency_safe(&canonical))
628 }
629
630 fn call_parallel_classification(&self, call: &ToolCall) -> (crate::ToolMutability, bool) {
631 let canonical = resolve_registered_tool_name(&self.registry, call.function.name.trim());
635 let args = bamboo_agent_core::parse_tool_args_best_effort(&call.function.arguments).0;
636 match self.registry.get(&canonical) {
637 Some(tool) => {
638 let class = tool.classify(&args);
639 (class.mutability, class.parallel_safe)
640 }
641 None => (
642 self.tool_mutability(&canonical),
643 self.tool_concurrency_safe(&canonical),
644 ),
645 }
646 }
647}
648
649pub struct BuiltinToolExecutorBuilder {
651 registry: ToolRegistry,
652 permission_checker: Option<Arc<dyn PermissionChecker>>,
653}
654
655impl BuiltinToolExecutorBuilder {
656 pub fn new() -> Self {
658 Self {
659 registry: ToolRegistry::new(),
660 permission_checker: None,
661 }
662 }
663
664 pub fn with_default_tools(self) -> Self {
666 BuiltinToolExecutor::register_builtin_tools(&self.registry, None);
667 self
668 }
669
670 pub fn with_filesystem_tool(self, name: &str) -> Result<Self, ToolError> {
672 match name {
673 "Read" => self.registry.register(ReadTool::new()),
674 "Write" => self.registry.register(WriteTool::new()),
675 "Edit" | "apply_patch" => self.registry.register(EditTool::new()),
677 "NotebookEdit" => self.registry.register(NotebookEditTool::new()),
678 _ => return Err(ToolError::NotFound(format!("Unknown tool: {}", name))),
679 }
680 .map_err(|e| ToolError::Execution(e.to_string()))?;
681 Ok(self)
682 }
683
684 pub fn with_command_tool(self, name: &str) -> Result<Self, ToolError> {
686 match name {
687 "Bash" => self.registry.register(BashTool::new()),
688 "BashOutput" => self.registry.register(BashOutputTool::new()),
689 "KillShell" => self.registry.register(KillShellTool::new()),
690 "Task" => self.registry.register(TaskTool::new()),
691 _ => return Err(ToolError::NotFound(format!("Unknown tool: {}", name))),
692 }
693 .map_err(|e| ToolError::Execution(e.to_string()))?;
694 Ok(self)
695 }
696
697 pub fn with_tool<T: Tool + 'static>(self, tool: T) -> Result<Self, ToolError> {
699 self.registry
700 .register(tool)
701 .map_err(|e| ToolError::Execution(e.to_string()))?;
702 Ok(self)
703 }
704
705 pub fn with_permission_checker(mut self, checker: Arc<dyn PermissionChecker>) -> Self {
707 self.permission_checker = Some(checker);
708 self
709 }
710
711 pub fn build(self) -> BuiltinToolExecutor {
713 BuiltinToolExecutor {
714 registry: self.registry,
715 permission_checker: self.permission_checker,
716 }
717 }
718}
719
720impl Default for BuiltinToolExecutorBuilder {
721 fn default() -> Self {
722 Self::new()
723 }
724}
725
726#[cfg(test)]
727mod tests {
728 use super::*;
729 use bamboo_agent_core::AgentEvent;
730 use bamboo_agent_core::FunctionCall;
731 use bamboo_agent_core::ToolCtx;
732 use bamboo_agent_core::ToolExecutionContext;
733 use bamboo_domain::tool_names::{normalize_tool_ref, BUILTIN_TOOL_NAMES};
734 use serde_json::json;
735 use std::sync::atomic::{AtomicUsize, Ordering};
736 use std::sync::Arc;
737 use tokio::fs;
738 use tokio::sync::mpsc;
739
740 use crate::tools::WriteTool;
741
742 fn make_tool_call(name: &str, args: serde_json::Value) -> ToolCall {
743 ToolCall {
744 id: "call_1".to_string(),
745 tool_type: "function".to_string(),
746 function: FunctionCall {
747 name: name.to_string(),
748 arguments: args.to_string(),
749 },
750 }
751 }
752
753 fn make_tool_call_with_raw_args(name: &str, raw_args: &str) -> ToolCall {
754 ToolCall {
755 id: "call_1".to_string(),
756 tool_type: "function".to_string(),
757 function: FunctionCall {
758 name: name.to_string(),
759 arguments: raw_args.to_string(),
760 },
761 }
762 }
763
764 fn make_executor(
765 permission_checker: Option<Arc<dyn PermissionChecker>>,
766 ) -> BuiltinToolExecutor {
767 let builder = BuiltinToolExecutorBuilder::new()
768 .with_tool(WriteTool::new())
769 .expect("register Write tool");
770
771 let builder = match permission_checker {
772 Some(checker) => builder.with_permission_checker(checker),
773 None => builder,
774 };
775
776 builder.build()
777 }
778
779 struct RecordingApprovalProxy {
780 requests: Arc<AtomicUsize>,
781 approve: bool,
782 }
783
784 #[async_trait]
785 impl crate::approval::ApprovalProxy for RecordingApprovalProxy {
786 async fn request_approval(&self, _ask: crate::approval::ApprovalAsk) -> bool {
787 self.requests.fetch_add(1, Ordering::SeqCst);
788 self.approve
789 }
790 }
791
792 #[test]
793 fn test_normalize_tool_ref_accepts_claude_style_names() {
794 assert_eq!(
795 normalize_tool_ref("default::Bash"),
796 Some("Bash".to_string())
797 );
798 }
799
800 #[test]
801 fn test_normalize_tool_ref_accepts_legacy_camel_aliases() {
802 assert_eq!(
803 normalize_tool_ref("default::fileExists"),
804 Some("FileExists".to_string())
805 );
806 assert_eq!(
807 normalize_tool_ref("default::getCurrentDir"),
808 Some("GetCurrentDir".to_string())
809 );
810 assert_eq!(
811 normalize_tool_ref("default::getFileInfo"),
812 Some("GetFileInfo".to_string())
813 );
814 assert_eq!(
815 normalize_tool_ref("default::setWorkspace"),
816 Some("SetWorkspace".to_string())
817 );
818 assert_eq!(
819 normalize_tool_ref("default::sleep"),
820 Some("Sleep".to_string())
821 );
822 }
823
824 #[test]
825 fn test_normalize_tool_ref_accepts_legacy_snake_case_aliases() {
826 assert_eq!(
827 normalize_tool_ref("default::execute_command"),
828 Some("Bash".to_string())
829 );
830 assert_eq!(
831 normalize_tool_ref("default::file_exists"),
832 Some("FileExists".to_string())
833 );
834 assert_eq!(
835 normalize_tool_ref("default::get_current_dir"),
836 Some("GetCurrentDir".to_string())
837 );
838 assert_eq!(
839 normalize_tool_ref("default::get_file_info"),
840 Some("GetFileInfo".to_string())
841 );
842 assert_eq!(
843 normalize_tool_ref("default::list_directory"),
844 Some("Glob".to_string())
845 );
846 assert_eq!(
847 normalize_tool_ref("default::memory_note"),
848 Some("memory_note".to_string())
849 );
850 assert_eq!(
851 normalize_tool_ref("default::read_file"),
852 Some("Read".to_string())
853 );
854 assert_eq!(
855 normalize_tool_ref("default::set_workspace"),
856 Some("SetWorkspace".to_string())
857 );
858 assert_eq!(
859 normalize_tool_ref("default::write_file"),
860 Some("Write".to_string())
861 );
862 }
863
864 #[test]
865 fn test_normalize_tool_ref_accepts_spawn_task_aliases() {
866 for alias in [
867 "default::spawn_session",
868 "default::sub_session",
869 "default::sub_task",
870 "default::team_agent",
871 "default::child_session",
872 ] {
873 assert_eq!(normalize_tool_ref(alias), Some("SubAgent".to_string()));
874 }
875 }
876
877 #[test]
878 fn test_normalize_tool_ref_accepts_server_overlay_tools() {
879 assert_eq!(normalize_tool_ref("compress_context"), None);
880 assert_eq!(
881 normalize_tool_ref("default::read_skill_resource"),
882 Some("read_skill_resource".to_string())
883 );
884 }
885
886 #[tokio::test]
887 async fn test_executor_accepts_legacy_read_file_path_argument() {
888 let dir = tempfile::tempdir().unwrap();
889 let file_path = dir.path().join("legacy-read.txt");
890 fs::write(&file_path, "legacy read content").await.unwrap();
891
892 let executor = BuiltinToolExecutor::new();
893 let call = make_tool_call("read_file", json!({"path": file_path}));
894
895 let result = executor.execute(&call).await.unwrap();
896 assert!(result.success);
897 assert!(result.result.contains("legacy read content"));
898 }
899
900 #[tokio::test]
901 async fn test_executor_accepts_legacy_list_directory_without_pattern() {
902 let dir = tempfile::tempdir().unwrap();
903 let file_path = dir.path().join("legacy-list.txt");
904 fs::write(&file_path, "legacy list content").await.unwrap();
905
906 let executor = BuiltinToolExecutor::new();
907 let call = make_tool_call("list_directory", json!({"path": dir.path()}));
908
909 let result = executor.execute(&call).await.unwrap();
910 assert!(result.success);
911 assert!(result.result.contains("legacy-list.txt"));
912 }
913
914 #[tokio::test]
915 async fn test_executor_accepts_canonical_read_with_path_argument() {
916 let dir = tempfile::tempdir().unwrap();
917 let file_path = dir.path().join("canonical-read.txt");
918 fs::write(&file_path, "canonical read content")
919 .await
920 .unwrap();
921
922 let executor = BuiltinToolExecutor::new();
923 let call = make_tool_call("Read", json!({"path": file_path}));
924
925 let result = executor.execute(&call).await.unwrap();
926 assert!(result.success);
927 assert!(result.result.contains("canonical read content"));
928 }
929
930 #[tokio::test]
931 async fn test_executor_accepts_canonical_glob_without_pattern_when_path_present() {
932 let dir = tempfile::tempdir().unwrap();
933 let file_path = dir.path().join("canonical-list.txt");
934 fs::write(&file_path, "canonical list content")
935 .await
936 .unwrap();
937
938 let executor = BuiltinToolExecutor::new();
939 let call = make_tool_call("Glob", json!({"path": dir.path()}));
940
941 let result = executor.execute(&call).await.unwrap();
942 assert!(result.success);
943 assert!(result.result.contains("canonical-list.txt"));
944 }
945
946 #[test]
947 fn test_executor_workspace_mutability_depends_on_path_argument() {
948 let executor = BuiltinToolExecutor::new();
949 let get_call = make_tool_call("Workspace", json!({}));
950 let set_call = make_tool_call("Workspace", json!({"path": "/tmp"}));
951
952 assert_eq!(
953 executor.call_mutability(&get_call),
954 crate::ToolMutability::ReadOnly
955 );
956 assert!(executor.call_concurrency_safe(&get_call));
957
958 assert_eq!(
959 executor.call_mutability(&set_call),
960 crate::ToolMutability::Mutating
961 );
962 assert!(!executor.call_concurrency_safe(&set_call));
963 }
964
965 #[test]
966 fn call_parallel_classification_matches_individual_methods() {
967 let executor = BuiltinToolExecutor::new();
975 let cases: &[(&str, serde_json::Value)] = &[
976 ("Read", json!({})),
977 ("Grep", json!({"pattern": "x"})),
978 (
979 "Write",
980 json!({"file_path": "/tmp/par_cls.txt", "content": "y"}),
981 ),
982 ("Bash", json!({"command": "echo hi"})),
983 ("Workspace", json!({})),
984 ("Workspace", json!({"path": "/tmp"})),
985 ];
986
987 for (name, args) in cases {
988 let call = make_tool_call(name, args.clone());
989 let expected_mutability = executor.call_mutability(&call);
990 let expected_concurrency = executor.call_concurrency_safe(&call);
991 let (mutability, concurrency) = executor.call_parallel_classification(&call);
992 assert_eq!(
993 mutability, expected_mutability,
994 "mutability mismatch for {name} ({args})"
995 );
996 assert_eq!(
997 concurrency, expected_concurrency,
998 "concurrency mismatch for {name} ({args})"
999 );
1000 }
1001 }
1002
1003 #[test]
1004 fn list_tools_snapshot_is_stable_across_calls() {
1005 let executor = BuiltinToolExecutor::new();
1010 let first: Vec<String> = executor
1011 .list_tools()
1012 .into_iter()
1013 .map(|s| s.function.name)
1014 .collect();
1015 let second: Vec<String> = executor
1016 .list_tools()
1017 .into_iter()
1018 .map(|s| s.function.name)
1019 .collect();
1020 assert!(!first.is_empty(), "builtin executor should expose tools");
1021 assert_eq!(
1022 first, second,
1023 "list_tools() must be deterministic per round"
1024 );
1025 }
1026
1027 #[tokio::test]
1028 async fn test_executor_recovers_truncated_json_arguments() {
1029 let dir = tempfile::tempdir().unwrap();
1030 let path = dir.path().join("recovered-write.txt");
1031
1032 let malformed_args = format!(
1034 r#"{{"file_path":"{}","content":"recovered content""#,
1035 path.display()
1036 );
1037
1038 let executor = BuiltinToolExecutor::new();
1039 let call = make_tool_call_with_raw_args("Write", &malformed_args);
1040
1041 let result = executor
1042 .execute(&call)
1043 .await
1044 .expect("truncated JSON should be auto-repaired");
1045 assert!(result.success);
1046
1047 let written = fs::read_to_string(&path)
1048 .await
1049 .expect("file should be written");
1050 assert_eq!(written, "recovered content");
1051 }
1052
1053 #[test]
1054 fn test_normalize_tool_ref_rejects_unknown_tool() {
1055 assert_eq!(normalize_tool_ref("default::search"), None);
1056 }
1057
1058 #[test]
1059 fn test_executor_does_not_expose_legacy_tools() {
1060 let executor = BuiltinToolExecutor::new();
1061 let tool_names: Vec<String> = executor
1062 .list_tools()
1063 .into_iter()
1064 .map(|schema| schema.function.name)
1065 .collect();
1066
1067 for legacy in ["claude_code", "search_in_file", "search_in_project"] {
1068 assert!(!tool_names.iter().any(|name| name == legacy));
1069 }
1070 }
1071
1072 #[test]
1073 fn test_critical_tool_schemas_match_claude_shapes() {
1074 let executor = BuiltinToolExecutor::new();
1075 let tools = executor.list_tools();
1076
1077 let get_params = |name: &str| {
1078 tools
1079 .iter()
1080 .find(|tool| tool.function.name == name)
1081 .unwrap()
1082 .function
1083 .parameters
1084 .clone()
1085 };
1086
1087 let grep = get_params("Grep");
1088 assert_eq!(grep["required"], json!(["pattern"]));
1089 assert_eq!(
1090 grep["properties"]["output_mode"]["enum"],
1091 json!(["content", "files_with_matches", "count"])
1092 );
1093 assert!(grep["properties"]["-A"].is_object());
1094 assert!(grep["properties"]["-B"].is_object());
1095 assert!(grep["properties"]["-C"].is_object());
1096 assert!(grep["properties"]["-n"].is_object());
1097 assert!(grep["properties"]["-i"].is_object());
1098
1099 let edit = get_params("Edit");
1100 assert_eq!(edit["required"], json!(["file_path"]));
1101 assert_eq!(edit["properties"]["old_string"]["type"], "string");
1102 assert_eq!(edit["properties"]["new_string"]["type"], "string");
1103 assert_eq!(edit["properties"]["patch"]["type"], "string");
1104 assert_eq!(edit["properties"]["replace_all"]["type"], "boolean");
1105 assert!(edit.get("oneOf").is_none());
1106
1107 assert_eq!(edit["properties"]["patch"]["type"], "string");
1110 assert_eq!(edit["properties"]["line_number"]["type"], "integer");
1111
1112 let bash = get_params("Bash");
1113 assert_eq!(bash["required"], json!(["command"]));
1114 assert_eq!(bash["properties"]["run_in_background"]["type"], "boolean");
1115 assert_eq!(bash["properties"]["workdir"]["type"], "string");
1116
1117 let bash_output = get_params("BashOutput");
1118 assert_eq!(bash_output["required"], json!(["bash_id"]));
1119 assert_eq!(bash_output["properties"]["filter"]["type"], "string");
1120 }
1121
1122 #[test]
1123 fn test_tool_schemas_avoid_openai_forbidden_top_level_keywords() {
1124 let executor = BuiltinToolExecutor::new();
1125 let tools = executor.list_tools();
1126 let forbidden = ["oneOf", "anyOf", "allOf", "not", "enum"];
1127
1128 for tool in tools {
1129 let params = &tool.function.parameters;
1130 assert_eq!(
1131 params["type"], "object",
1132 "tool '{}' parameters must be a top-level object schema",
1133 tool.function.name
1134 );
1135 for key in forbidden {
1136 assert!(
1137 params.get(key).is_none(),
1138 "tool '{}' parameters contains forbidden top-level keyword '{}'",
1139 tool.function.name,
1140 key
1141 );
1142 }
1143 }
1144 }
1145
1146 #[test]
1147 fn test_executor_has_all_builtin_tools() {
1148 let executor = BuiltinToolExecutor::new();
1149 let tools = executor.list_tools();
1150
1151 assert_eq!(tools.len(), BUILTIN_TOOL_NAMES.len());
1152
1153 let tool_names: Vec<String> = tools.iter().map(|t| t.function.name.clone()).collect();
1154 for tool_name in BUILTIN_TOOL_NAMES {
1155 assert!(tool_names.contains(&tool_name.to_string()));
1156 }
1157 }
1158
1159 #[test]
1160 fn test_executor_builds_enhanced_prompt() {
1161 let executor = BuiltinToolExecutor::new();
1162 let prompt = executor.build_enhanced_prompt(GuideBuildContext::default());
1163 assert!(prompt.contains("## Tool Usage Guidelines"));
1164 assert!(prompt.contains("**Read**"));
1165 }
1166
1167 #[test]
1168 fn test_executor_builder_empty() {
1169 let executor = BuiltinToolExecutorBuilder::new().build();
1170 assert!(executor.list_tools().is_empty());
1171 }
1172
1173 #[test]
1174 fn test_executor_builder_with_default_tools() {
1175 let executor = BuiltinToolExecutorBuilder::new()
1176 .with_default_tools()
1177 .build();
1178 assert_eq!(executor.list_tools().len(), BUILTIN_TOOL_NAMES.len());
1179 }
1180
1181 #[test]
1182 fn test_executor_builder_with_specific_tool() {
1183 let executor = BuiltinToolExecutorBuilder::new()
1184 .with_filesystem_tool("Read")
1185 .unwrap()
1186 .build();
1187
1188 let tools = executor.list_tools();
1189 assert_eq!(tools.len(), 1);
1190 assert_eq!(tools[0].function.name, "Read");
1191 }
1192
1193 #[tokio::test]
1194 async fn test_executor_skips_permission_checks_without_checker() {
1195 let executor = make_executor(None);
1196 let path = "/tmp/executor_permission_none.txt";
1197 let _ = fs::remove_file(path).await;
1198
1199 let call = make_tool_call("Write", json!({"file_path": path, "content": "ok"}));
1200 let result = executor.execute(&call).await.expect("execute tool");
1201
1202 assert!(result.success);
1203 let _ = fs::remove_file(path).await;
1204 }
1205
1206 #[tokio::test]
1207 async fn test_executor_with_permission_checker_enforces_checks() {
1208 let checker = Arc::new(crate::permission::DenyDangerousPermissionChecker);
1209 let executor = make_executor(Some(checker));
1210 let path = "/tmp/executor_permission_denied.txt";
1211 let _ = fs::remove_file(path).await;
1212
1213 let call = make_tool_call("Write", json!({"file_path": path, "content": "nope"}));
1214 let result = executor.execute(&call).await;
1215
1216 assert!(matches!(result, Err(ToolError::Execution(_))));
1217 assert!(fs::metadata(path).await.is_err());
1218 }
1219
1220 #[tokio::test]
1221 async fn test_bypass_permissions_skips_checker() {
1222 let config = Arc::new(crate::permission::PermissionConfig::new());
1228 let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
1229 let executor = BuiltinToolExecutorBuilder::new()
1230 .with_tool(BashTool::new())
1231 .expect("register Bash tool")
1232 .with_permission_checker(checker)
1233 .build();
1234 let dir = tempfile::tempdir().unwrap();
1235 let path = dir.path().join("bypass_allows_bash.txt");
1236 let command = format!("printf ordinary > {}", path.display());
1237 let approval_requests = Arc::new(AtomicUsize::new(0));
1238 let proxy: Arc<dyn crate::approval::ApprovalProxy> = Arc::new(RecordingApprovalProxy {
1239 requests: approval_requests.clone(),
1240 approve: true,
1241 });
1242 let (event_tx, mut event_rx) = mpsc::channel(8);
1243
1244 let call = make_tool_call("Bash", json!({"command": command}));
1245 let ctx = ToolExecutionContext {
1246 session_id: Some("s-bypass"),
1247 tool_call_id: &call.id,
1248 event_tx: Some(&event_tx),
1249 available_tool_schemas: None,
1250 bypass_permissions: true,
1251 auto_approve_permissions: false,
1252 plan_read_only: false,
1253 can_async_resume: false,
1254 bash_completion_sink: None,
1255 pre_parsed_args: None,
1256 };
1257 let result = crate::approval::with_approval_proxy(
1258 Some(proxy),
1259 executor.execute_with_context(&call, ctx),
1260 )
1261 .await;
1262
1263 assert!(result.is_ok(), "bypass should allow the write: {result:?}");
1264 assert_eq!(fs::read_to_string(&path).await.unwrap(), "ordinary");
1265 assert_eq!(
1266 approval_requests.load(Ordering::SeqCst),
1267 0,
1268 "ordinary bypassed child command must not invoke the parent reviewer"
1269 );
1270 assert!(
1271 event_rx.try_recv().is_err(),
1272 "ordinary bypassed child command must not emit a human approval event"
1273 );
1274 }
1275
1276 #[tokio::test]
1277 async fn hook_allow_skips_configured_ask_for_exact_call() {
1278 let dir = tempfile::tempdir().unwrap();
1279 let path = dir.path().join("hook-allowed.txt");
1280 let path_str = path.to_str().unwrap().to_string();
1281 let config = Arc::new(crate::permission::PermissionConfig::new());
1282 config.set_ask_rules([format!("Write({}/**)", dir.path().to_str().unwrap())]);
1283 let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
1284 let executor = make_executor(Some(checker));
1285 let call = make_tool_call(
1286 "Write",
1287 json!({"file_path": path_str, "content": "allowed by hook"}),
1288 );
1289 let ctx = ToolExecutionContext {
1290 session_id: Some("s-hook-allow"),
1291 tool_call_id: &call.id,
1292 event_tx: None,
1293 available_tool_schemas: None,
1294 bypass_permissions: false,
1295 auto_approve_permissions: false,
1296 plan_read_only: false,
1297 can_async_resume: false,
1298 bash_completion_sink: None,
1299 pre_parsed_args: None,
1300 };
1301
1302 let result = crate::with_hook_permission_override(
1303 Some(crate::HookPermissionOverride::Allow),
1304 &call.id,
1305 executor.execute_with_context(&call, ctx),
1306 )
1307 .await;
1308
1309 assert!(
1310 result.is_ok(),
1311 "hook allow should skip ordinary ask: {result:?}"
1312 );
1313 assert_eq!(fs::read_to_string(path).await.unwrap(), "allowed by hook");
1314 assert_eq!(
1315 crate::current_hook_permission_override(&call.id),
1316 None,
1317 "the one-call override must not leak"
1318 );
1319 }
1320
1321 #[tokio::test]
1322 async fn hook_allow_cannot_skip_hard_dangerous_parent_review() {
1323 let config = Arc::new(crate::permission::PermissionConfig::new());
1324 let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
1325 let executor = BuiltinToolExecutorBuilder::new()
1326 .with_tool(BashTool::new())
1327 .expect("register Bash tool")
1328 .with_permission_checker(checker)
1329 .build();
1330 let dir = tempfile::tempdir().unwrap();
1331 let path = dir.path().join("hard-dangerous-must-not-run.txt");
1332 let command = format!("eval 'printf denied > {}'", path.display());
1333 let requests = Arc::new(AtomicUsize::new(0));
1334 let proxy: Arc<dyn crate::approval::ApprovalProxy> = Arc::new(RecordingApprovalProxy {
1335 requests: requests.clone(),
1336 approve: false,
1337 });
1338 let call = make_tool_call("Bash", json!({"command": command}));
1339 let ctx = ToolExecutionContext {
1340 session_id: Some("s-hook-hard-dangerous"),
1341 tool_call_id: &call.id,
1342 event_tx: None,
1343 available_tool_schemas: None,
1344 bypass_permissions: true,
1345 auto_approve_permissions: false,
1346 plan_read_only: false,
1347 can_async_resume: false,
1348 bash_completion_sink: None,
1349 pre_parsed_args: None,
1350 };
1351
1352 let result = crate::with_hook_permission_override(
1353 Some(crate::HookPermissionOverride::Allow),
1354 &call.id,
1355 crate::approval::with_approval_proxy(
1356 Some(proxy),
1357 executor.execute_with_context(&call, ctx),
1358 ),
1359 )
1360 .await;
1361
1362 assert!(
1363 matches!(result, Err(ToolError::Execution(ref message)) if message.contains("denied by host")),
1364 "hard-dangerous review must remain authoritative: {result:?}"
1365 );
1366 assert_eq!(requests.load(Ordering::SeqCst), 1);
1367 assert!(!path.exists());
1368 }
1369
1370 #[tokio::test]
1371 async fn hook_allow_cannot_skip_explicit_deny() {
1372 let dir = tempfile::tempdir().unwrap();
1373 let path = dir.path().join("explicit-deny.txt");
1374 let path_str = path.to_str().unwrap().to_string();
1375 let config = Arc::new(crate::permission::PermissionConfig::new());
1376 config.deny_scoped_session_permission(
1377 "s-hook-explicit-deny",
1378 crate::permission::PermissionType::WriteFile,
1379 path_str.clone(),
1380 );
1381 let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
1382 let executor = make_executor(Some(checker));
1383 let call = make_tool_call(
1384 "Write",
1385 json!({"file_path": path_str, "content": "must not be written"}),
1386 );
1387 let ctx = ToolExecutionContext {
1388 session_id: Some("s-hook-explicit-deny"),
1389 tool_call_id: &call.id,
1390 event_tx: None,
1391 available_tool_schemas: None,
1392 bypass_permissions: false,
1393 auto_approve_permissions: false,
1394 plan_read_only: false,
1395 can_async_resume: false,
1396 bash_completion_sink: None,
1397 pre_parsed_args: None,
1398 };
1399
1400 let result = crate::with_hook_permission_override(
1401 Some(crate::HookPermissionOverride::Allow),
1402 &call.id,
1403 executor.execute_with_context(&call, ctx),
1404 )
1405 .await;
1406
1407 assert!(
1408 matches!(result, Err(ToolError::Execution(ref message)) if message.contains("remembered session decision")),
1409 "explicit deny must remain authoritative: {result:?}"
1410 );
1411 assert!(!path.exists());
1412 }
1413
1414 #[tokio::test]
1415 async fn test_forced_ask_rule_overrides_bypass() {
1416 let config = Arc::new(crate::permission::PermissionConfig::new());
1420 let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
1421 let executor = BuiltinToolExecutorBuilder::new()
1422 .with_tool(BashTool::new())
1423 .expect("register Bash tool")
1424 .with_permission_checker(checker)
1425 .build();
1426 let dir = tempfile::tempdir().unwrap();
1427 let denied_path = dir.path().join("forced-denied.txt");
1428 let denied_command = format!("eval 'printf denied > {}'", denied_path.display());
1429 let denied_requests = Arc::new(AtomicUsize::new(0));
1430 let deny_proxy: Arc<dyn crate::approval::ApprovalProxy> =
1431 Arc::new(RecordingApprovalProxy {
1432 requests: denied_requests.clone(),
1433 approve: false,
1434 });
1435
1436 let denied_call = make_tool_call("Bash", json!({"command": denied_command}));
1437 let denied_ctx = ToolExecutionContext {
1438 session_id: Some("s-forced"),
1439 tool_call_id: &denied_call.id,
1440 event_tx: None,
1441 available_tool_schemas: None,
1442 bypass_permissions: true,
1443 auto_approve_permissions: false,
1444 plan_read_only: false,
1445 can_async_resume: false,
1446 bash_completion_sink: None,
1447 pre_parsed_args: None,
1448 };
1449 let denied = crate::approval::with_approval_proxy(
1450 Some(deny_proxy),
1451 executor.execute_with_context(&denied_call, denied_ctx),
1452 )
1453 .await;
1454
1455 assert!(
1456 matches!(denied, Err(ToolError::Execution(ref message)) if message.contains("denied by host")),
1457 "parent denial must block forced-ask execution under bypass: {denied:?}"
1458 );
1459 assert_eq!(denied_requests.load(Ordering::SeqCst), 1);
1460 assert!(!denied_path.exists(), "denied command must not execute");
1461
1462 let approved_path = dir.path().join("forced-approved.txt");
1463 let approved_command = format!("eval 'printf approved > {}'", approved_path.display());
1464 let approved_requests = Arc::new(AtomicUsize::new(0));
1465 let approve_proxy: Arc<dyn crate::approval::ApprovalProxy> =
1466 Arc::new(RecordingApprovalProxy {
1467 requests: approved_requests.clone(),
1468 approve: true,
1469 });
1470 let approved_call = make_tool_call("Bash", json!({"command": approved_command}));
1471 let approved_ctx = ToolExecutionContext {
1472 session_id: Some("s-forced"),
1473 tool_call_id: &approved_call.id,
1474 event_tx: None,
1475 available_tool_schemas: None,
1476 bypass_permissions: true,
1477 auto_approve_permissions: false,
1478 plan_read_only: false,
1479 can_async_resume: false,
1480 bash_completion_sink: None,
1481 pre_parsed_args: None,
1482 };
1483 let approved = crate::approval::with_approval_proxy(
1484 Some(approve_proxy),
1485 executor.execute_with_context(&approved_call, approved_ctx),
1486 )
1487 .await;
1488
1489 assert!(
1490 approved.is_ok(),
1491 "parent approval must allow forced-ask execution under bypass: {approved:?}"
1492 );
1493 assert_eq!(approved_requests.load(Ordering::SeqCst), 1);
1494 assert_eq!(fs::read_to_string(approved_path).await.unwrap(), "approved");
1495 }
1496
1497 #[tokio::test]
1498 async fn auto_executes_forced_ask_without_proxy_or_human_event() {
1499 let config = Arc::new(crate::permission::PermissionConfig::new());
1500 let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
1501 let executor = BuiltinToolExecutorBuilder::new()
1502 .with_tool(BashTool::new())
1503 .expect("register Bash tool")
1504 .with_permission_checker(checker)
1505 .build();
1506 let dir = tempfile::tempdir().unwrap();
1507 let path = dir.path().join("auto-forced.txt");
1508 let command = format!("eval 'printf auto > {}'", path.display());
1509 let approval_requests = Arc::new(AtomicUsize::new(0));
1510 let proxy: Arc<dyn crate::approval::ApprovalProxy> = Arc::new(RecordingApprovalProxy {
1511 requests: approval_requests.clone(),
1512 approve: false,
1513 });
1514 let (event_tx, mut event_rx) = mpsc::channel(8);
1515 let call = make_tool_call("Bash", json!({"command": command}));
1516 let ctx = ToolExecutionContext {
1517 session_id: Some("s-auto"),
1518 tool_call_id: &call.id,
1519 event_tx: Some(&event_tx),
1520 available_tool_schemas: None,
1521 bypass_permissions: false,
1522 auto_approve_permissions: true,
1523 plan_read_only: false,
1524 can_async_resume: false,
1525 bash_completion_sink: None,
1526 pre_parsed_args: None,
1527 };
1528
1529 let result = crate::approval::with_approval_proxy(
1530 Some(proxy),
1531 executor.execute_with_context(&call, ctx),
1532 )
1533 .await;
1534
1535 assert!(result.is_ok(), "Auto should execute directly: {result:?}");
1536 assert_eq!(fs::read_to_string(path).await.unwrap(), "auto");
1537 assert_eq!(approval_requests.load(Ordering::SeqCst), 0);
1538 assert!(
1539 event_rx.try_recv().is_err(),
1540 "Auto must not emit an interactive approval request"
1541 );
1542 }
1543
1544 #[tokio::test]
1545 async fn auto_never_overrides_guardian_read_only_hard_deny() {
1546 let config = Arc::new(crate::permission::PermissionConfig::new());
1547 let base: Arc<dyn crate::permission::PermissionChecker> = Arc::new(
1548 crate::permission::ConfigPermissionChecker::new(config.clone()),
1549 );
1550 let checker = Arc::new(crate::permission::GuardianReadOnlyChecker::new(base));
1551 let executor = BuiltinToolExecutorBuilder::new()
1552 .with_tool(BashTool::new())
1553 .expect("register Bash tool")
1554 .with_permission_checker(checker)
1555 .build();
1556 let dir = tempfile::tempdir().unwrap();
1557 let path = dir.path().join("guardian-mutation.txt");
1558 let command = format!("printf blocked > {}", path.display());
1559 let call = make_tool_call("Bash", json!({"command": command}));
1560 let ctx = ToolExecutionContext {
1561 session_id: Some("guardian-auto"),
1562 tool_call_id: &call.id,
1563 event_tx: None,
1564 available_tool_schemas: None,
1565 bypass_permissions: false,
1566 auto_approve_permissions: true,
1567 plan_read_only: false,
1568 can_async_resume: false,
1569 bash_completion_sink: None,
1570 pre_parsed_args: None,
1571 };
1572
1573 let error = executor
1574 .execute_with_context(&call, ctx)
1575 .await
1576 .expect_err("Auto must retain Guardian read-only authority");
1577
1578 assert!(error.to_string().contains("Guardian reviewer is read-only"));
1579 assert!(!path.exists());
1580 }
1581
1582 #[tokio::test]
1583 async fn test_explicit_deny_overrides_bypass() {
1584 let dir = tempfile::tempdir().unwrap();
1585 let path = dir.path().join("explicit-deny.txt");
1586 let path_str = path.to_str().unwrap();
1587 let config = Arc::new(crate::permission::PermissionConfig::new());
1588 config.add_rule(crate::permission::PermissionRule::new(
1589 crate::permission::PermissionType::WriteFile,
1590 path_str,
1591 false,
1592 ));
1593 let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
1594 let executor = make_executor(Some(checker));
1595 let call = make_tool_call(
1596 "Write",
1597 json!({"file_path": path_str, "content": "blocked"}),
1598 );
1599 let ctx = ToolExecutionContext {
1600 session_id: Some("s-explicit-deny"),
1601 tool_call_id: &call.id,
1602 event_tx: None,
1603 available_tool_schemas: None,
1604 bypass_permissions: true,
1605 auto_approve_permissions: false,
1606 plan_read_only: false,
1607 can_async_resume: false,
1608 bash_completion_sink: None,
1609 pre_parsed_args: None,
1610 };
1611
1612 let result = executor.execute_with_context(&call, ctx).await;
1613 assert!(
1614 matches!(result, Err(ToolError::Execution(ref message)) if message.contains("explicit policy")),
1615 "explicit deny must beat bypass: {result:?}"
1616 );
1617 assert!(!path.exists());
1618 }
1619
1620 #[tokio::test]
1621 async fn test_explicit_delete_deny_overrides_bypass() {
1622 let config = Arc::new(crate::permission::PermissionConfig::new());
1623 config.add_rule(crate::permission::PermissionRule::new(
1624 crate::permission::PermissionType::DeleteOperation,
1625 "rm child-to-preserve",
1626 false,
1627 ));
1628 let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
1629 let executor = BuiltinToolExecutorBuilder::new()
1630 .with_tool(BashTool::new())
1631 .expect("register Bash tool")
1632 .with_permission_checker(checker)
1633 .build();
1634 let call = make_tool_call("Bash", json!({"command": "rm child-to-preserve"}));
1635 let ctx = ToolExecutionContext {
1636 session_id: Some("s-explicit-delete-deny"),
1637 tool_call_id: &call.id,
1638 event_tx: None,
1639 available_tool_schemas: None,
1640 bypass_permissions: true,
1641 auto_approve_permissions: false,
1642 plan_read_only: false,
1643 can_async_resume: false,
1644 bash_completion_sink: None,
1645 pre_parsed_args: None,
1646 };
1647
1648 let result = executor.execute_with_context(&call, ctx).await;
1649 assert!(
1650 matches!(result, Err(ToolError::Execution(ref message)) if message.contains("explicit policy")),
1651 "explicit delete deny must beat bypass: {result:?}"
1652 );
1653 }
1654
1655 #[tokio::test]
1656 async fn plan_auto_denies_mutation_but_allows_read_without_a_checker() {
1657 let executor = BuiltinToolExecutor::new();
1658 let dir = tempfile::tempdir().unwrap();
1659 let path = dir.path().join("plan-auto.txt");
1660 let write = make_tool_call(
1661 "Write",
1662 json!({"file_path": path, "content": "must not run"}),
1663 );
1664 let write_ctx = ToolExecutionContext {
1665 session_id: Some("plan-auto"),
1666 tool_call_id: &write.id,
1667 event_tx: None,
1668 available_tool_schemas: None,
1669 bypass_permissions: false,
1670 auto_approve_permissions: true,
1671 plan_read_only: true,
1672 can_async_resume: false,
1673 bash_completion_sink: None,
1674 pre_parsed_args: None,
1675 };
1676 let denied = executor.execute_with_context(&write, write_ctx).await;
1677 assert!(matches!(
1678 denied,
1679 Err(ToolError::Execution(ref message)) if message.contains("Plan mode")
1680 ));
1681 assert!(tokio::fs::metadata(&path).await.is_err());
1682
1683 tokio::fs::write(&path, "readable").await.unwrap();
1684 let read = make_tool_call("Read", json!({"file_path": path}));
1685 let read_ctx = ToolExecutionContext {
1686 session_id: Some("plan-auto"),
1687 tool_call_id: &read.id,
1688 event_tx: None,
1689 available_tool_schemas: None,
1690 bypass_permissions: false,
1691 auto_approve_permissions: true,
1692 plan_read_only: true,
1693 can_async_resume: false,
1694 bash_completion_sink: None,
1695 pre_parsed_args: None,
1696 };
1697 let allowed = executor
1698 .execute_with_context(&read, read_ctx)
1699 .await
1700 .unwrap();
1701 assert!(allowed.success);
1702 }
1703
1704 #[tokio::test]
1705 async fn auto_request_permissions_fails_without_creating_a_pause() {
1706 let executor = BuiltinToolExecutor::new();
1707 let (event_tx, mut event_rx) = mpsc::channel(4);
1708 let call = make_tool_call("request_permissions", json!({}));
1709 let ctx = ToolExecutionContext {
1710 session_id: Some("auto-no-prompt"),
1711 tool_call_id: &call.id,
1712 event_tx: Some(&event_tx),
1713 available_tool_schemas: None,
1714 bypass_permissions: false,
1715 auto_approve_permissions: true,
1716 plan_read_only: false,
1717 can_async_resume: false,
1718 bash_completion_sink: None,
1719 pre_parsed_args: None,
1720 };
1721
1722 let result = executor.execute_with_context_outcome(&call, ctx).await;
1723 assert!(matches!(
1724 result,
1725 Err(ToolError::Execution(ref message)) if message.contains("cannot request expanded permissions")
1726 ));
1727 assert!(event_rx.try_recv().is_err());
1728 }
1729
1730 #[tokio::test]
1731 async fn interactive_gate_returns_synthesized_approval_pause() {
1732 let config = Arc::new(crate::permission::PermissionConfig::new());
1740 config.set_ask_rules(["Write(/etc/**)".to_string()]);
1741 config.register_session_workspace("s-interactive", "/workspace/project");
1742 let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
1743 let executor = make_executor(Some(checker));
1744
1745 let (tx, mut rx) = mpsc::channel(8);
1746 let call = make_tool_call(
1747 "Write",
1748 json!({"file_path": "/etc/gated.conf", "content": "x"}),
1749 );
1750 let ctx = ToolExecutionContext {
1751 session_id: Some("s-interactive"),
1752 tool_call_id: &call.id,
1753 event_tx: Some(&tx),
1754 available_tool_schemas: None,
1755 bypass_permissions: false,
1756 auto_approve_permissions: false,
1757 plan_read_only: false,
1758 can_async_resume: false,
1759 bash_completion_sink: None,
1760 pre_parsed_args: None,
1761 };
1762
1763 let result = executor
1764 .execute_with_context(&call, ctx)
1765 .await
1766 .expect("interactive gate should pause (Ok), not error");
1767
1768 assert_eq!(
1769 result.display_preference.as_deref(),
1770 Some("request_permissions"),
1771 "interactive gate must return the request_permissions pause result"
1772 );
1773 assert!(result.result.contains("awaiting_permission_approval"));
1774 let payload: serde_json::Value = serde_json::from_str(&result.result).expect("payload");
1775 let request = &payload["permission_request"];
1776 assert_eq!(request["request_id"], call.id);
1777 assert_eq!(request["session_id"], "s-interactive");
1778 assert_eq!(request["workspace_path"], "/workspace/project");
1779 assert_eq!(request["reason_code"], "configured_always_ask");
1780 assert_eq!(
1781 request["allowed_decisions"],
1782 json!(["allow_once", "deny_once"])
1783 );
1784 assert_eq!(payload["options"], json!(["Approve", "Deny"]));
1785 assert!(fs::metadata("/etc/gated.conf").await.is_err());
1786
1787 let ev = rx.recv().await.expect("approval event should be emitted");
1788 assert!(
1789 matches!(ev, AgentEvent::ToolApprovalRequested { tool_name, .. } if tool_name == "Write")
1790 );
1791 }
1792
1793 #[tokio::test]
1794 async fn check_permissions_for_returns_none_when_permitted() {
1795 let executor = make_executor(None);
1798 let call = make_tool_call("Read", json!({"file_path": "/tmp/whatever"}));
1799 let ctx = ToolExecutionContext::none(&call.id);
1800 let decision = executor
1801 .check_permissions_for(&call, &ctx)
1802 .await
1803 .expect("no checker means no gate");
1804 assert!(decision.is_none(), "no checker must yield Ok(None)");
1805 }
1806
1807 struct HostStub {
1810 approve: bool,
1811 }
1812
1813 #[async_trait]
1814 impl crate::approval::ApprovalProxy for HostStub {
1815 async fn request_approval(&self, _ask: crate::approval::ApprovalAsk) -> bool {
1816 self.approve
1817 }
1818 }
1819
1820 #[tokio::test]
1821 async fn approval_proxy_grant_lets_gated_tool_proceed() {
1822 let dir = tempfile::tempdir().unwrap();
1827 let path = dir.path().join("approved.txt");
1828 let path_str = path.to_str().unwrap().to_string();
1829 let config = Arc::new(crate::permission::PermissionConfig::new());
1830 config.set_ask_rules([format!("Write({}/**)", dir.path().to_str().unwrap())]);
1831 let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
1832 let executor = make_executor(Some(checker));
1833
1834 let call = make_tool_call("Write", json!({"file_path": path_str, "content": "ok"}));
1835 let ctx = ToolExecutionContext {
1836 session_id: Some("s-worker"),
1837 tool_call_id: &call.id,
1838 event_tx: None,
1839 available_tool_schemas: None,
1840 bypass_permissions: false,
1841 auto_approve_permissions: false,
1842 plan_read_only: false,
1843 can_async_resume: false,
1844 bash_completion_sink: None,
1845 pre_parsed_args: None,
1846 };
1847
1848 let proxy: Arc<dyn crate::approval::ApprovalProxy> = Arc::new(HostStub { approve: true });
1849 let result = crate::approval::with_approval_proxy(
1850 Some(proxy),
1851 executor.execute_with_context(&call, ctx),
1852 )
1853 .await;
1854
1855 assert!(
1856 result.is_ok(),
1857 "host grant should let the write through: {result:?}"
1858 );
1859 assert_eq!(fs::read_to_string(&path).await.unwrap(), "ok");
1860 }
1861
1862 #[tokio::test]
1863 async fn approval_proxy_deny_fails_gated_tool_closed() {
1864 let dir = tempfile::tempdir().unwrap();
1867 let path = dir.path().join("denied.txt");
1868 let path_str = path.to_str().unwrap().to_string();
1869 let config = Arc::new(crate::permission::PermissionConfig::new());
1870 config.set_ask_rules([format!("Write({}/**)", dir.path().to_str().unwrap())]);
1871 let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
1872 let executor = make_executor(Some(checker));
1873
1874 let call = make_tool_call("Write", json!({"file_path": path_str, "content": "nope"}));
1875 let ctx = ToolExecutionContext {
1876 session_id: Some("s-worker"),
1877 tool_call_id: &call.id,
1878 event_tx: None,
1879 available_tool_schemas: None,
1880 bypass_permissions: false,
1881 auto_approve_permissions: false,
1882 plan_read_only: false,
1883 can_async_resume: false,
1884 bash_completion_sink: None,
1885 pre_parsed_args: None,
1886 };
1887
1888 let proxy: Arc<dyn crate::approval::ApprovalProxy> = Arc::new(HostStub { approve: false });
1889 let result = crate::approval::with_approval_proxy(
1890 Some(proxy),
1891 executor.execute_with_context(&call, ctx),
1892 )
1893 .await;
1894
1895 assert!(
1896 matches!(result, Err(ToolError::Execution(ref m)) if m.contains("denied by host")),
1897 "host deny should fail the tool closed: {result:?}"
1898 );
1899 assert!(fs::metadata(&path).await.is_err());
1900 }
1901
1902 #[tokio::test]
1903 async fn tool_can_stream_events_via_execute_with_context() {
1904 struct StreamingTool;
1905
1906 #[async_trait]
1907 impl Tool for StreamingTool {
1908 fn name(&self) -> &str {
1909 "streaming_tool"
1910 }
1911
1912 fn description(&self) -> &str {
1913 "streams one token"
1914 }
1915
1916 fn parameters_schema(&self) -> serde_json::Value {
1917 json!({"type":"object","properties":{}})
1918 }
1919
1920 async fn invoke(
1921 &self,
1922 _args: serde_json::Value,
1923 ctx: ToolCtx,
1924 ) -> Result<ToolOutcome, ToolError> {
1925 ctx.emit(AgentEvent::Token {
1926 content: "stream".to_string(),
1927 })
1928 .await;
1929 Ok(ToolOutcome::Completed(ToolResult {
1930 success: true,
1931 result: "ok".to_string(),
1932 display_preference: None,
1933 images: Vec::new(),
1934 }))
1935 }
1936 }
1937
1938 let executor = BuiltinToolExecutor::new();
1939 executor
1940 .register_tool(StreamingTool)
1941 .expect("register streaming tool");
1942
1943 let (tx, mut rx) = mpsc::channel(8);
1944 let call = make_tool_call("streaming_tool", json!({}));
1945
1946 let result = executor
1947 .execute_with_context(
1948 &call,
1949 ToolExecutionContext {
1950 session_id: Some("s1"),
1951 tool_call_id: &call.id,
1952 event_tx: Some(&tx),
1953 available_tool_schemas: None,
1954 bypass_permissions: false,
1955 auto_approve_permissions: false,
1956 plan_read_only: false,
1957 can_async_resume: false,
1958 bash_completion_sink: None,
1959 pre_parsed_args: None,
1960 },
1961 )
1962 .await
1963 .expect("execute tool");
1964
1965 assert!(result.success);
1966 assert_eq!(result.result, "ok");
1967
1968 let ev = rx.recv().await.expect("expected streamed event");
1969 assert!(
1970 matches!(ev, AgentEvent::ToolToken { tool_call_id, content } if tool_call_id == "call_1" && content == "stream")
1971 );
1972 }
1973
1974 #[tokio::test]
1975 async fn removed_legacy_tools_return_not_found() {
1976 let executor = BuiltinToolExecutor::new();
1977
1978 for legacy in ["claude_code", "search_in_file", "search_in_project"] {
1979 let call = make_tool_call(legacy, json!({}));
1980 let result = executor.execute(&call).await;
1981 assert!(matches!(result, Err(ToolError::NotFound(_))));
1982 }
1983 }
1984
1985 #[tokio::test]
1986 async fn executor_prefers_exact_tool_name_before_builtin_alias() {
1987 struct CustomSpawnSessionTool;
1988
1989 #[async_trait]
1990 impl Tool for CustomSpawnSessionTool {
1991 fn name(&self) -> &str {
1992 "spawn_session"
1993 }
1994
1995 fn description(&self) -> &str {
1996 "custom tool for regression coverage"
1997 }
1998
1999 fn parameters_schema(&self) -> serde_json::Value {
2000 json!({"type":"object","properties":{}})
2001 }
2002
2003 async fn invoke(
2004 &self,
2005 _args: serde_json::Value,
2006 _ctx: ToolCtx,
2007 ) -> Result<ToolOutcome, ToolError> {
2008 Ok(ToolOutcome::Completed(ToolResult {
2009 success: true,
2010 result: "custom-spawn-session".to_string(),
2011 display_preference: None,
2012 images: Vec::new(),
2013 }))
2014 }
2015 }
2016
2017 let executor = BuiltinToolExecutorBuilder::new()
2018 .with_tool(CustomSpawnSessionTool)
2019 .expect("register custom spawn_session tool")
2020 .build();
2021
2022 let call = make_tool_call("spawn_session", json!({}));
2023 let result = executor.execute(&call).await.expect("execute custom tool");
2024 assert!(result.success);
2025 assert_eq!(result.result, "custom-spawn-session");
2026 }
2027
2028 struct EchoArgsTool;
2033
2034 #[async_trait]
2035 impl Tool for EchoArgsTool {
2036 fn name(&self) -> &str {
2037 "echo_args"
2038 }
2039 fn description(&self) -> &str {
2040 "echoes the `v` arg"
2041 }
2042 fn parameters_schema(&self) -> serde_json::Value {
2043 json!({"type":"object","properties":{"v":{"type":"string"}}})
2044 }
2045 async fn invoke(
2046 &self,
2047 args: serde_json::Value,
2048 _ctx: ToolCtx,
2049 ) -> Result<ToolOutcome, ToolError> {
2050 let v = args
2051 .get("v")
2052 .and_then(serde_json::Value::as_str)
2053 .unwrap_or("<none>")
2054 .to_string();
2055 Ok(ToolOutcome::Completed(ToolResult {
2056 success: true,
2057 result: v,
2058 display_preference: None,
2059 images: Vec::new(),
2060 }))
2061 }
2062 }
2063
2064 fn ctx_with_pre_parsed<'a>(
2065 call_id: &'a str,
2066 pre_parsed: Option<&'a serde_json::Value>,
2067 ) -> ToolExecutionContext<'a> {
2068 ToolExecutionContext {
2069 session_id: Some("s-106"),
2070 tool_call_id: call_id,
2071 event_tx: None,
2072 available_tool_schemas: None,
2073 bypass_permissions: false,
2074 auto_approve_permissions: false,
2075 plan_read_only: false,
2076 can_async_resume: false,
2077 bash_completion_sink: None,
2078 pre_parsed_args: pre_parsed,
2079 }
2080 }
2081
2082 #[tokio::test]
2083 async fn execute_with_context_reuses_pre_parsed_args_without_reparsing() {
2084 let executor = BuiltinToolExecutor::new();
2090 executor.register_tool(EchoArgsTool).expect("register echo");
2091
2092 let call = make_tool_call("echo_args", json!({"v": "raw"}));
2093 let pre_parsed = json!({"v": "preparsed"});
2094 let ctx = ctx_with_pre_parsed(&call.id, Some(&pre_parsed));
2095
2096 let result = executor
2097 .execute_with_context(&call, ctx)
2098 .await
2099 .expect("execute echo tool");
2100 assert_eq!(
2101 result.result, "preparsed",
2102 "executor must reuse pre_parsed_args, not re-parse the raw string"
2103 );
2104 }
2105
2106 #[tokio::test]
2107 async fn execute_with_context_parses_raw_when_no_pre_parsed_args() {
2108 let executor = BuiltinToolExecutor::new();
2112 executor.register_tool(EchoArgsTool).expect("register echo");
2113
2114 let call = make_tool_call("echo_args", json!({"v": "raw"}));
2115 let ctx = ctx_with_pre_parsed(&call.id, None);
2116
2117 let result = executor
2118 .execute_with_context(&call, ctx)
2119 .await
2120 .expect("execute echo tool");
2121 assert_eq!(
2122 result.result, "raw",
2123 "without pre_parsed_args the executor parses the raw string as before"
2124 );
2125 }
2126
2127 #[tokio::test]
2128 async fn execute_with_context_malformed_args_repair_unchanged_without_pre_parsed() {
2129 let dir = tempfile::tempdir().unwrap();
2133 let path = dir.path().join("recovered-no-preparsed.txt");
2134 let malformed_args = format!(
2135 r#"{{"file_path":"{}","content":"recovered content""#,
2136 path.display()
2137 );
2138
2139 let executor = BuiltinToolExecutor::new();
2140 let call = make_tool_call_with_raw_args("Write", &malformed_args);
2141 let ctx = ctx_with_pre_parsed(&call.id, None);
2142
2143 let result = executor
2144 .execute_with_context(&call, ctx)
2145 .await
2146 .expect("truncated JSON should be auto-repaired");
2147 assert!(result.success);
2148 let written = fs::read_to_string(&path).await.expect("file written");
2149 assert_eq!(written, "recovered content");
2150 }
2151}