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