1use std::collections::BTreeMap;
2use std::panic::{catch_unwind, AssertUnwindSafe};
3use std::sync::Arc;
4
5use async_trait::async_trait;
6use bamboo_agent_core::tools::input_guard::{check_parsed_tool_input, check_raw_tool_input};
7use bamboo_agent_core::{
8 parse_tool_args_best_effort, Tool, ToolCall, ToolError, ToolExecutionContext, ToolExecutor,
9 ToolOutcome, ToolResult, ToolSchema,
10};
11use bamboo_domain::{canonical_tool_name, resolve_tool_reference_name};
12
13use crate::guide::{context::GuideBuildContext, EnhancedPromptBuilder, ToolGuide};
14use crate::permission::{check_permissions, PermissionChecker, PermissionError};
15use crate::tools::{
16 BashInputTool, BashOutputTool, BashTool, ConclusionWithOptionsTool, EditTool,
17 EnterPlanModeTool, ExitPlanModeTool, GetFileInfoTool, GlobTool, GrepTool, JsReplTool,
18 KillShellTool, NotebookEditTool, ReadTool, RequestPermissionsTool, SessionNoteTool, SleepTool,
19 TaskTool, ToolRegistry, UpdateGoalTool, WebFetchTool, WebSearchTool, WorkspaceTool, WriteTool,
20};
21use bamboo_llm::Config;
22use bamboo_plugin_protocol::{
23 FileChangedV1, NoopToolEventPublisher, ToolEventContextV1, ToolEventPublisher, ToolEventV1,
24};
25use tokio::sync::RwLock;
26
27fn preview_for_log(value: &str, max_chars: usize) -> String {
28 let mut iter = value.chars();
29 let mut preview = String::new();
30 for _ in 0..max_chars {
31 match iter.next() {
32 Some(ch) => preview.push(ch),
33 None => break,
34 }
35 }
36 if iter.next().is_some() {
37 preview.push_str("...");
38 }
39 preview.replace('\n', "\\n").replace('\r', "\\r")
40}
41
42fn copy_legacy_arg_if_missing(
43 args: &mut serde_json::Map<String, serde_json::Value>,
44 from: &str,
45 to: &str,
46) {
47 if args.contains_key(to) {
48 return;
49 }
50 if let Some(value) = args.get(from).cloned() {
51 args.insert(to.to_string(), value);
52 }
53}
54
55fn normalize_legacy_builtin_args(
56 raw_tool_name: &str,
57 args: &mut serde_json::Map<String, serde_json::Value>,
58) {
59 match raw_tool_name {
60 "read_file" | "write_file" | "Read" | "Write" | "apply_patch" => {
61 copy_legacy_arg_if_missing(args, "path", "file_path");
62 }
63 "execute_command" | "Bash" => {
64 copy_legacy_arg_if_missing(args, "cmd", "command");
65 }
66 "list_directory" | "Glob" => {
67 let should_default_pattern = raw_tool_name == "list_directory"
68 || args.contains_key("path")
69 || args.contains_key("recursive");
70 if should_default_pattern && !args.contains_key("pattern") {
71 let recursive = args
72 .get("recursive")
73 .and_then(serde_json::Value::as_bool)
74 .unwrap_or(false);
75 let pattern = if recursive { "**/*" } else { "*" };
76 args.insert(
77 "pattern".to_string(),
78 serde_json::Value::String(pattern.to_string()),
79 );
80 }
81 args.remove("recursive");
82 }
83 _ => {}
84 }
85}
86
87fn resolve_registered_tool_name(registry: &ToolRegistry, reference: &str) -> Option<String> {
88 resolve_tool_reference_name(reference, |candidate| registry.contains(candidate))
89}
90
91fn normalize_resolved_builtin_args(
96 reference: &str,
97 execution_name: &str,
98 args: &mut serde_json::Value,
99) {
100 if !matches!(execution_name, "Read" | "Write" | "Edit" | "Bash" | "Glob") {
101 return;
102 }
103 let unqualified = reference
104 .trim()
105 .rsplit("::")
106 .next()
107 .unwrap_or(reference)
108 .trim();
109 if let Some(args_obj) = args.as_object_mut() {
110 normalize_legacy_builtin_args(unqualified, args_obj);
111 }
112}
113
114pub struct BuiltinToolExecutor {
116 registry: ToolRegistry,
117 permission_checker: Option<Arc<dyn PermissionChecker>>,
118 framework_builtin_tools: BTreeMap<String, Arc<dyn Tool>>,
122 tool_event_publisher: Arc<dyn ToolEventPublisher>,
123}
124
125impl BuiltinToolExecutor {
126 fn default_tool_event_publisher() -> Arc<dyn ToolEventPublisher> {
127 Arc::new(NoopToolEventPublisher)
128 }
129
130 pub fn new() -> Self {
132 let registry = ToolRegistry::new();
133 let framework_builtin_tools = Self::register_builtin_tools(®istry, None);
134 Self {
135 registry,
136 permission_checker: None,
137 framework_builtin_tools,
138 tool_event_publisher: Self::default_tool_event_publisher(),
139 }
140 }
141
142 pub fn new_with_permissions(permission_checker: Arc<dyn PermissionChecker>) -> Self {
144 let registry = ToolRegistry::new();
145 let framework_builtin_tools = Self::register_builtin_tools(®istry, None);
146 Self {
147 registry,
148 permission_checker: Some(permission_checker),
149 framework_builtin_tools,
150 tool_event_publisher: Self::default_tool_event_publisher(),
151 }
152 }
153
154 pub fn new_with_config(config: Arc<RwLock<Config>>) -> Self {
159 let registry = ToolRegistry::new();
160 let framework_builtin_tools = Self::register_builtin_tools(®istry, Some(config));
161 Self {
162 registry,
163 permission_checker: None,
164 framework_builtin_tools,
165 tool_event_publisher: Self::default_tool_event_publisher(),
166 }
167 }
168
169 pub fn new_with_config_and_permissions(
171 config: Arc<RwLock<Config>>,
172 permission_checker: Arc<dyn PermissionChecker>,
173 ) -> Self {
174 let registry = ToolRegistry::new();
175 let framework_builtin_tools = Self::register_builtin_tools(®istry, Some(config));
176 Self {
177 registry,
178 permission_checker: Some(permission_checker),
179 framework_builtin_tools,
180 tool_event_publisher: Self::default_tool_event_publisher(),
181 }
182 }
183
184 pub fn with_registry(registry: ToolRegistry) -> Self {
186 Self {
187 registry,
188 permission_checker: None,
189 framework_builtin_tools: BTreeMap::new(),
190 tool_event_publisher: Self::default_tool_event_publisher(),
191 }
192 }
193
194 pub fn with_registry_and_permissions(
201 registry: ToolRegistry,
202 permission_checker: Arc<dyn PermissionChecker>,
203 ) -> Self {
204 Self {
205 registry,
206 permission_checker: Some(permission_checker),
207 framework_builtin_tools: BTreeMap::new(),
208 tool_event_publisher: Self::default_tool_event_publisher(),
209 }
210 }
211
212 pub fn with_tool_event_publisher(mut self, publisher: Arc<dyn ToolEventPublisher>) -> Self {
214 self.tool_event_publisher = publisher;
215 self
216 }
217
218 pub fn registry(&self) -> &ToolRegistry {
220 &self.registry
221 }
222
223 fn pending_file_changed(
224 &self,
225 tool_name: &str,
226 tool: &Arc<dyn Tool>,
227 args: &serde_json::Value,
228 ) -> Option<FileChangedV1> {
229 let builtin = self.framework_builtin_tools.get(tool_name)?;
230 if !Arc::ptr_eq(builtin, tool) {
231 return None;
232 }
233 let path_field = match tool_name {
234 "Write" | "Edit" => "file_path",
235 "NotebookEdit" => "notebook_path",
236 _ => return None,
237 };
238 let path = args.get(path_field)?.as_str()?.trim();
239 FileChangedV1::bounded_from(path).ok()
240 }
241
242 fn publish_successful_file_change(
243 &self,
244 ctx: &ToolExecutionContext<'_>,
245 tool_name: &str,
246 data: FileChangedV1,
247 ) {
248 let Some(session_id) = ctx.session_id else {
249 return;
250 };
251 let Some(root_session_id) = ctx.root_session_id else {
252 return;
253 };
254 let Ok(context) = ToolEventContextV1::bounded_from(
255 session_id,
256 root_session_id,
257 tool_name,
258 ctx.tool_call_id,
259 ) else {
260 return;
261 };
262 let Ok(event) = ToolEventV1::file_changed(context, data) else {
263 return;
264 };
265
266 let publisher = self.tool_event_publisher.as_ref();
269 let _ = catch_unwind(AssertUnwindSafe(|| publisher.try_publish(event)));
270 }
271
272 fn register_builtin_tools(
274 registry: &ToolRegistry,
275 config: Option<Arc<RwLock<Config>>>,
276 ) -> BTreeMap<String, Arc<dyn Tool>> {
277 let mut framework_tools = BTreeMap::new();
278 let _ = config;
279 let _ = registry.register(ConclusionWithOptionsTool::new());
281 if let Ok((name, tool)) = Self::register_tracked_builtin(registry, BashTool::new()) {
282 framework_tools.insert(name, tool);
283 }
284 let _ = registry.register(BashInputTool::new());
285 let _ = registry.register(BashOutputTool::new());
286 if let Ok((name, tool)) = Self::register_tracked_builtin(registry, EditTool::new()) {
287 framework_tools.insert(name, tool);
288 }
289 let _ = registry.register(EnterPlanModeTool::new());
290 let _ = registry.register(ExitPlanModeTool::new());
291 let _ = registry.register(GetFileInfoTool::new());
293 if let Ok((name, tool)) = Self::register_tracked_builtin(registry, GlobTool::new()) {
294 framework_tools.insert(name, tool);
295 }
296 let _ = registry.register(GrepTool::new());
297 let _ = registry.register(UpdateGoalTool::new());
298 let _ = registry.register(JsReplTool::new());
299 let _ = registry.register(KillShellTool::new());
300 let _ = registry.register(SessionNoteTool::new());
301 if let Ok((name, tool)) = Self::register_tracked_builtin(registry, NotebookEditTool::new())
302 {
303 framework_tools.insert(name, tool);
304 }
305 if let Ok((name, tool)) = Self::register_tracked_builtin(registry, ReadTool::new()) {
306 framework_tools.insert(name, tool);
307 }
308 let _ = registry.register(RequestPermissionsTool::new());
309 let _ = registry.register(SleepTool::new());
310 let _ = registry.register(TaskTool::new());
311 let _ = registry.register(WebFetchTool::new());
312 let _ = registry.register(WebSearchTool::new());
313 let _ = registry.register(WorkspaceTool::new());
315 if let Ok((name, tool)) = Self::register_tracked_builtin(registry, WriteTool::new()) {
316 framework_tools.insert(name, tool);
317 }
318 framework_tools
319 }
320
321 fn register_tracked_builtin<T: Tool + 'static>(
322 registry: &ToolRegistry,
323 tool: T,
324 ) -> Result<(String, Arc<dyn Tool>), ToolError> {
325 let name = tool.name().to_string();
326 let tool: Arc<dyn Tool> = Arc::new(tool);
327 registry
328 .register_shared(tool.clone())
329 .map_err(|error| ToolError::Execution(error.to_string()))?;
330 Ok((name, tool))
331 }
332
333 fn is_framework_builtin_instance(&self, execution_name: &str, tool: &Arc<dyn Tool>) -> bool {
334 self.framework_builtin_tools
335 .get(execution_name)
336 .is_some_and(|builtin| Arc::ptr_eq(builtin, tool))
337 }
338
339 fn normalize_registered_builtin_args(
340 &self,
341 reference: &str,
342 execution_name: &str,
343 tool: &Arc<dyn Tool>,
344 args: &mut serde_json::Value,
345 ) {
346 if self.is_framework_builtin_instance(execution_name, tool) {
347 normalize_resolved_builtin_args(reference, execution_name, args);
348 }
349 }
350
351 pub fn tool_schemas() -> Vec<ToolSchema> {
353 let registry = ToolRegistry::new();
354 let _ = Self::register_builtin_tools(®istry, None);
355 registry.list_tools()
356 }
357
358 pub fn register_tool<T: Tool + 'static>(&self, tool: T) -> Result<(), ToolError> {
360 self.registry
361 .register(tool)
362 .map_err(|e| ToolError::Execution(e.to_string()))
363 }
364
365 pub fn register_tool_with_guide<T, G>(&self, tool: T, guide: G) -> Result<(), ToolError>
367 where
368 T: Tool + 'static,
369 G: ToolGuide + 'static,
370 {
371 self.registry
372 .register_with_guide(tool, guide)
373 .map_err(|e| ToolError::Execution(e.to_string()))
374 }
375
376 pub fn get_guide(&self, tool_name: &str) -> Option<Arc<dyn ToolGuide>> {
378 self.registry.get_guide(tool_name)
379 }
380
381 fn parse_execution_args(
382 &self,
383 call: &ToolCall,
384 ctx: &ToolExecutionContext<'_>,
385 ) -> serde_json::Value {
386 if let Some(pre_parsed) = ctx.pre_parsed_args {
387 return pre_parsed.clone();
388 }
389 let args_raw = call.function.arguments.trim();
390 let (parsed, parse_warning) = parse_tool_args_best_effort(&call.function.arguments);
391 if let Some(warning) = parse_warning {
392 tracing::warn!(
393 "Builtin tool argument parsing fallback applied: session_id={:?}, tool_call_id={}, tool_name={}, args_len={}, args_preview=\"{}\", warning={}",
394 ctx.session_id,
395 call.id,
396 call.function.name,
397 args_raw.len(),
398 preview_for_log(args_raw, 180),
399 warning
400 );
401 }
402 parsed
403 }
404
405 async fn execute_registered_with_context_outcome(
406 &self,
407 call: &ToolCall,
408 execution_name: &str,
409 ctx: ToolExecutionContext<'_>,
410 ) -> Result<ToolOutcome, ToolError> {
411 let tool = self
412 .registry
413 .get(execution_name)
414 .ok_or_else(|| ToolError::NotFound(format!("Tool '{}' not found", execution_name)))?;
415 check_raw_tool_input(execution_name, &call.function.arguments)?;
416 let mut args = self.parse_execution_args(call, &ctx);
417 check_parsed_tool_input(execution_name, &args)?;
418 self.normalize_registered_builtin_args(
419 &call.function.name,
420 execution_name,
421 &tool,
422 &mut args,
423 );
424
425 if let Some(outcome) = self
426 .check_permissions_for_resolved(call, execution_name, &args, &ctx)
427 .await?
428 {
429 return Ok(outcome);
430 }
431
432 let publisher_enabled =
433 catch_unwind(AssertUnwindSafe(|| self.tool_event_publisher.is_enabled()))
434 .unwrap_or(false);
435 let pending_file_changed = publisher_enabled
436 .then(|| self.pending_file_changed(execution_name, &tool, &args))
437 .flatten();
438
439 let outcome = tool.invoke(args, ctx.to_tool_ctx()).await?;
440 if matches!(
441 &outcome,
442 ToolOutcome::Completed(result) if result.success
443 ) {
444 if let Some(data) = pending_file_changed {
445 self.publish_successful_file_change(&ctx, execution_name, data);
446 }
447 }
448 Ok(outcome)
449 }
450
451 pub fn build_enhanced_prompt(&self, context: GuideBuildContext) -> String {
453 EnhancedPromptBuilder::build(Some(&self.registry), &self.registry.list_tools(), &context)
454 }
455}
456
457fn permission_error_to_tool_error(error: PermissionError) -> ToolError {
458 match error {
459 PermissionError::CheckFailed(_) => ToolError::InvalidArguments(error.to_string()),
460 _ => ToolError::Execution(error.to_string()),
461 }
462}
463
464impl Default for BuiltinToolExecutor {
465 fn default() -> Self {
466 Self::new()
467 }
468}
469
470#[async_trait]
471impl ToolExecutor for BuiltinToolExecutor {
472 async fn execute(&self, call: &ToolCall) -> Result<ToolResult, ToolError> {
473 self.execute_with_context(call, ToolExecutionContext::none(&call.id))
474 .await
475 }
476
477 async fn execute_with_context(
478 &self,
479 call: &ToolCall,
480 ctx: ToolExecutionContext<'_>,
481 ) -> Result<ToolResult, ToolError> {
482 self.execute_with_context_outcome(call, ctx)
483 .await
484 .map(ToolOutcome::into_tool_result)
485 }
486
487 async fn execute_with_context_outcome(
488 &self,
489 call: &ToolCall,
490 ctx: ToolExecutionContext<'_>,
491 ) -> Result<ToolOutcome, ToolError> {
492 let reference = call.function.name.trim();
493 let tool_name =
494 resolve_registered_tool_name(&self.registry, reference).ok_or_else(|| {
495 ToolError::NotFound(format!("Tool '{}' not found", call.function.name))
496 })?;
497 self.execute_registered_with_context_outcome(call, &tool_name, ctx)
498 .await
499 }
500
501 async fn execute_exact_with_context_outcome(
502 &self,
503 call: &ToolCall,
504 execution_name: &str,
505 ctx: ToolExecutionContext<'_>,
506 ) -> Result<ToolOutcome, ToolError> {
507 self.execute_registered_with_context_outcome(call, execution_name, ctx)
508 .await
509 }
510
511 async fn check_permissions_for(
535 &self,
536 call: &ToolCall,
537 ctx: &ToolExecutionContext<'_>,
538 ) -> Result<Option<ToolOutcome>, ToolError> {
539 let reference = call.function.name.trim();
540 let tool_name = resolve_registered_tool_name(&self.registry, reference)
541 .unwrap_or_else(|| canonical_tool_name(reference));
542 let mut args = if let Some(pre_parsed) = ctx.pre_parsed_args {
543 pre_parsed.clone()
544 } else {
545 parse_tool_args_best_effort(&call.function.arguments).0
546 };
547 if let Some(tool) = self.registry.get(&tool_name) {
548 self.normalize_registered_builtin_args(reference, &tool_name, &tool, &mut args);
549 }
550 self.check_permissions_for_resolved(call, &tool_name, &args, ctx)
551 .await
552 }
553
554 async fn check_permissions_for_exact(
555 &self,
556 call: &ToolCall,
557 execution_name: &str,
558 ctx: &ToolExecutionContext<'_>,
559 ) -> Result<Option<ToolOutcome>, ToolError> {
560 let tool = self
561 .registry
562 .get(execution_name)
563 .ok_or_else(|| ToolError::NotFound(format!("Tool '{}' not found", execution_name)))?;
564 let mut args = if let Some(pre_parsed) = ctx.pre_parsed_args {
565 pre_parsed.clone()
566 } else {
567 parse_tool_args_best_effort(&call.function.arguments).0
568 };
569 self.normalize_registered_builtin_args(
570 call.function.name.trim(),
571 execution_name,
572 &tool,
573 &mut args,
574 );
575 self.check_permissions_for_resolved(call, execution_name, &args, ctx)
576 .await
577 }
578
579 async fn check_permissions_for_resolved(
580 &self,
581 call: &ToolCall,
582 execution_name: &str,
583 resolved_args: &serde_json::Value,
584 ctx: &ToolExecutionContext<'_>,
585 ) -> Result<Option<ToolOutcome>, ToolError> {
586 let tool_name = execution_name.to_string();
587 let args = resolved_args.clone();
588 if ctx.auto_approve_permissions && tool_name.eq_ignore_ascii_case("request_permissions") {
589 return Err(ToolError::Execution(
590 "Auto mode cannot request expanded permissions; operate within existing hard boundaries"
591 .to_string(),
592 ));
593 }
594 if ctx.plan_read_only && !crate::orchestrator::plan_mode_allows_tool(&tool_name) {
595 return Err(ToolError::Execution(format!(
596 "Plan mode: {tool_name} operation blocked"
597 )));
598 }
599 let Some(permission_checker) = &self.permission_checker else {
600 return Ok(None);
601 };
602 let hook_permission_override = crate::current_hook_permission_override(&call.id);
603
604 if let Some(contexts) =
605 check_permissions(&tool_name, &args).map_err(permission_error_to_tool_error)?
606 {
607 let proactive_permission_request =
608 tool_name.eq_ignore_ascii_case("request_permissions");
609 for context in contexts {
610 let resource = context.resource.clone();
611 let operation_summary = context.operation_description.clone();
612 let risk_level = context.risk_level();
613 let permission_type = context.permission_type;
614 let platform_hard_deny = permission_checker.hard_deny_reason(&context);
615 let config = permission_checker.permission_config();
616 let proxy = crate::approval::current_approval_proxy();
617 let request = if let Some(config) = config.as_ref() {
618 if proactive_permission_request && proxy.is_some() {
619 return Err(ToolError::Execution(
620 "request_permissions requires the local typed decision protocol; a boolean approval relay cannot create remembered authority"
621 .to_string(),
622 ));
623 }
624 let mut supported_decisions = if proxy.is_some() {
628 crate::permission::PermissionRequest::forced_decisions()
629 } else {
630 crate::permission::PermissionRequest::ordinary_decisions(true)
631 };
632 if proactive_permission_request {
633 supported_decisions.retain(|decision| {
638 *decision != crate::permission::PermissionDecisionKind::AllowOnce
639 });
640 }
641 let workspace_path = ctx
646 .session_id
647 .and_then(|session_id| config.session_workspace(session_id));
648 match config.evaluate(crate::permission::PermissionEvaluation {
649 request_id: call.id.clone(),
650 session_id: ctx.session_id.unwrap_or_default().to_string(),
651 workspace_path,
652 tool_name: tool_name.clone(),
653 tool_args: args.clone(),
654 permission_type,
655 resource: resource.clone(),
656 operation_summary: operation_summary.clone(),
657 risk_level,
658 bypass_requested: ctx.bypass_permissions,
659 auto_approve_requested: ctx.auto_approve_permissions,
660 platform_hard_deny,
661 consume_once: true,
662 supported_decisions,
663 }) {
664 crate::permission::PermissionOutcome::Allow { .. } => continue,
665 crate::permission::PermissionOutcome::Deny { reason, .. } => {
666 return Err(ToolError::Execution(reason.message));
667 }
668 crate::permission::PermissionOutcome::Ask(request)
669 if matches!(
670 hook_permission_override,
671 Some(crate::HookPermissionOverride::Allow)
672 ) && !proactive_permission_request
673 && request.reason_code
674 != crate::permission::PermissionReasonCode::HardDangerous =>
675 {
676 continue;
677 }
678 crate::permission::PermissionOutcome::Ask(request) => request,
679 }
680 } else {
681 if proactive_permission_request {
682 return Err(ToolError::Execution(
683 "request_permissions requires a typed PermissionConfig and cannot fall back to a display-string approval"
684 .to_string(),
685 ));
686 }
687 if let Some(reason) = platform_hard_deny {
690 return Err(ToolError::Execution(reason));
691 }
692 let force_ask =
693 permission_checker.requires_forced_confirmation(&tool_name, &args);
694 let hook_allows = matches!(
695 hook_permission_override,
696 Some(crate::HookPermissionOverride::Allow)
697 );
698 if ctx.auto_approve_permissions
699 || ((ctx.bypass_permissions || hook_allows) && !force_ask)
700 {
701 continue;
702 }
703 let decision = if force_ask {
704 permission_checker.check_or_request_forced(context).await
705 } else if let Some(session_id) = ctx.session_id {
706 permission_checker
707 .check_or_request_for_session(session_id, context)
708 .await
709 } else {
710 permission_checker.check_or_request(context).await
711 };
712 match decision {
713 Ok(true) => continue,
714 Ok(false) => {
715 return Err(ToolError::Execution(format!(
716 "Permission denied for: {}",
717 resource
718 )));
719 }
720 Err(PermissionError::ConfirmationRequired { .. }) => {
721 crate::permission::PermissionRequest {
722 request_id: call.id.clone(),
723 request_generation:
724 crate::permission::PermissionRequest::fresh_generation(),
725 session_id: ctx.session_id.unwrap_or_default().to_string(),
726 workspace_path: None,
727 tool_name: tool_name.clone(),
728 permission_type,
729 resource: resource.clone(),
730 operation_summary: operation_summary.clone(),
731 risk_level,
732 reason_code: if force_ask {
733 crate::permission::PermissionReasonCode::ConfiguredAlwaysAsk
734 } else {
735 crate::permission::PermissionReasonCode::RiskThreshold
736 },
737 effective_mode: bamboo_config::settings::PermissionMode::Default,
738 bypass_requested: ctx.bypass_permissions,
739 auto_approve_requested: ctx.auto_approve_permissions,
740 policy_revision: 0,
741 matched_rule: None,
742 allowed_decisions:
743 crate::permission::PermissionRequest::forced_decisions(),
744 suggested_matchers: crate::permission::conservative_matchers(
745 permission_type,
746 &resource,
747 ),
748 }
749 }
750 Err(other) => return Err(permission_error_to_tool_error(other)),
751 }
752 };
753
754 if let Some(proxy) = proxy {
758 let approved = proxy
759 .request_approval(crate::approval::ApprovalAsk {
760 tool_name: tool_name.clone(),
761 permission: permission_type.description().to_string(),
762 resource: resource.clone(),
763 permission_request: Some(request.clone()),
764 })
765 .await;
766 if approved {
767 continue;
768 }
769 return Err(ToolError::Execution(format!(
770 "Permission denied by host for: {}",
771 resource
772 )));
773 }
774
775 if let Some(tx) = ctx.event_tx {
778 let _ = tx
779 .send(bamboo_agent_core::AgentEvent::ToolApprovalRequested {
780 tool_call_id: call.id.clone(),
781 tool_name: tool_name.clone(),
782 parameters: args.clone(),
783 })
784 .await;
785
786 let question = format!(
787 "**Permission required**\n\nThe `{}` tool needs approval to {} on:\n\n`{}`",
788 tool_name,
789 permission_type.description(),
790 resource
791 );
792 if let Some(config) = config {
793 config.register_pending_request(request.clone());
794 }
795 let payload = serde_json::json!({
796 "status": "awaiting_permission_approval",
797 "question": question,
798 "permission_type": permission_type,
799 "resource": resource,
800 "options": ["Approve", "Deny"],
801 "allow_custom": false,
802 "permission_request": request,
803 });
804 return Ok(Some(ToolOutcome::Completed(ToolResult {
805 success: true,
806 result: payload.to_string(),
807 display_preference: Some("request_permissions".to_string()),
808 images: Vec::new(),
809 })));
810 }
811
812 return Err(ToolError::Execution(format!(
813 "Permission approval required for: {}",
814 resource
815 )));
816 }
817 }
818
819 Ok(None)
820 }
821
822 fn list_tools(&self) -> Vec<ToolSchema> {
823 self.registry.list_tools()
824 }
825
826 fn owns_exact_tool(&self, tool_name: &str) -> bool {
827 self.registry.contains(tool_name)
828 }
829
830 fn tool_mutability(&self, tool_name: &str) -> crate::ToolMutability {
831 let resolved = resolve_registered_tool_name(&self.registry, tool_name);
832 resolved
833 .as_deref()
834 .and_then(|name| self.registry.get(name))
835 .map(|tool| tool.classify(&serde_json::Value::Null).mutability)
836 .unwrap_or_else(|| crate::classify_tool(&canonical_tool_name(tool_name)))
837 }
838
839 fn call_mutability(&self, call: &ToolCall) -> crate::ToolMutability {
840 self.call_parallel_classification(call).0
841 }
842
843 fn tool_concurrency_safe(&self, tool_name: &str) -> bool {
844 let resolved = resolve_registered_tool_name(&self.registry, tool_name);
845 resolved
846 .as_deref()
847 .and_then(|name| self.registry.get(name))
848 .map(|tool| tool.classify(&serde_json::Value::Null).parallel_safe)
849 .unwrap_or_else(|| self.tool_mutability(tool_name) == crate::ToolMutability::ReadOnly)
850 }
851
852 fn call_concurrency_safe(&self, call: &ToolCall) -> bool {
853 self.call_parallel_classification(call).1
854 }
855
856 fn call_parallel_classification(&self, call: &ToolCall) -> (crate::ToolMutability, bool) {
857 let reference = call.function.name.trim();
861 let resolved = resolve_registered_tool_name(&self.registry, reference);
862 let mut args = bamboo_agent_core::parse_tool_args_best_effort(&call.function.arguments).0;
863 match resolved.as_deref().and_then(|execution_name| {
864 self.registry
865 .get(execution_name)
866 .map(|tool| (execution_name, tool))
867 }) {
868 Some((execution_name, tool)) => {
869 self.normalize_registered_builtin_args(reference, execution_name, &tool, &mut args);
870 let class = tool.classify(&args);
871 (class.mutability, class.parallel_safe)
872 }
873 None => (
874 self.tool_mutability(reference),
875 self.tool_concurrency_safe(reference),
876 ),
877 }
878 }
879}
880
881pub struct BuiltinToolExecutorBuilder {
883 registry: ToolRegistry,
884 permission_checker: Option<Arc<dyn PermissionChecker>>,
885 framework_builtin_tools: BTreeMap<String, Arc<dyn Tool>>,
886 tool_event_publisher: Arc<dyn ToolEventPublisher>,
887}
888
889impl BuiltinToolExecutorBuilder {
890 pub fn new() -> Self {
892 Self {
893 registry: ToolRegistry::new(),
894 permission_checker: None,
895 framework_builtin_tools: BTreeMap::new(),
896 tool_event_publisher: BuiltinToolExecutor::default_tool_event_publisher(),
897 }
898 }
899
900 pub fn with_default_tools(mut self) -> Self {
902 self.framework_builtin_tools
903 .extend(BuiltinToolExecutor::register_builtin_tools(
904 &self.registry,
905 None,
906 ));
907 self
908 }
909
910 pub fn with_filesystem_tool(mut self, name: &str) -> Result<Self, ToolError> {
912 let (name, tool) = match name {
913 "Read" => {
914 BuiltinToolExecutor::register_tracked_builtin(&self.registry, ReadTool::new())?
915 }
916 "Write" => {
917 BuiltinToolExecutor::register_tracked_builtin(&self.registry, WriteTool::new())?
918 }
919 "Edit" | "apply_patch" => {
921 BuiltinToolExecutor::register_tracked_builtin(&self.registry, EditTool::new())?
922 }
923 "NotebookEdit" => BuiltinToolExecutor::register_tracked_builtin(
924 &self.registry,
925 NotebookEditTool::new(),
926 )?,
927 _ => return Err(ToolError::NotFound(format!("Unknown tool: {}", name))),
928 };
929 self.framework_builtin_tools.insert(name, tool);
930 Ok(self)
931 }
932
933 pub fn with_command_tool(mut self, name: &str) -> Result<Self, ToolError> {
935 if name == "Bash" {
936 let (name, tool) =
937 BuiltinToolExecutor::register_tracked_builtin(&self.registry, BashTool::new())?;
938 self.framework_builtin_tools.insert(name, tool);
939 return Ok(self);
940 }
941 match name {
942 "BashOutput" => self.registry.register(BashOutputTool::new()),
943 "KillShell" => self.registry.register(KillShellTool::new()),
944 "Task" => self.registry.register(TaskTool::new()),
945 _ => return Err(ToolError::NotFound(format!("Unknown tool: {}", name))),
946 }
947 .map_err(|e| ToolError::Execution(e.to_string()))?;
948 Ok(self)
949 }
950
951 pub fn with_tool<T: Tool + 'static>(self, tool: T) -> Result<Self, ToolError> {
953 self.registry
954 .register(tool)
955 .map_err(|e| ToolError::Execution(e.to_string()))?;
956 Ok(self)
957 }
958
959 pub fn with_permission_checker(mut self, checker: Arc<dyn PermissionChecker>) -> Self {
961 self.permission_checker = Some(checker);
962 self
963 }
964
965 pub fn with_tool_event_publisher(mut self, publisher: Arc<dyn ToolEventPublisher>) -> Self {
967 self.tool_event_publisher = publisher;
968 self
969 }
970
971 pub fn build(self) -> BuiltinToolExecutor {
973 BuiltinToolExecutor {
974 registry: self.registry,
975 permission_checker: self.permission_checker,
976 framework_builtin_tools: self.framework_builtin_tools,
977 tool_event_publisher: self.tool_event_publisher,
978 }
979 }
980}
981
982impl Default for BuiltinToolExecutorBuilder {
983 fn default() -> Self {
984 Self::new()
985 }
986}
987
988#[cfg(test)]
989mod tests {
990 use super::*;
991 use bamboo_agent_core::AgentEvent;
992 use bamboo_agent_core::FunctionCall;
993 use bamboo_agent_core::ToolCtx;
994 use bamboo_agent_core::ToolExecutionContext;
995 use bamboo_domain::tool_names::{normalize_tool_ref, BUILTIN_TOOL_NAMES};
996 use bamboo_plugin_protocol::{
997 FileChangedV1, InMemoryToolEventRecorder, ToolEventContextV1, ToolEventPublishError,
998 ToolEventV1, MAX_TOOL_EVENT_PATH_BYTES,
999 };
1000 use serde_json::json;
1001 use std::sync::atomic::{AtomicUsize, Ordering};
1002 use std::sync::Arc;
1003 use tokio::fs;
1004 use tokio::sync::mpsc;
1005
1006 use crate::tools::WriteTool;
1007
1008 fn make_tool_call(name: &str, args: serde_json::Value) -> ToolCall {
1009 make_tool_call_with_id("call_1", name, args)
1010 }
1011
1012 #[tokio::test]
1013 async fn oversized_write_is_rejected_before_creating_a_file() {
1014 let dir = tempfile::tempdir().unwrap();
1015 let file = dir.path().join("oversized.txt");
1016 let call = make_tool_call(
1017 "write_file",
1018 json!({
1019 "path": file,
1020 "content": "x".repeat(1024 * 1024),
1021 }),
1022 );
1023 let error = BuiltinToolExecutor::new().execute(&call).await.unwrap_err();
1024 assert!(matches!(error, ToolError::InvalidArguments(_)));
1025 assert!(!file.exists());
1026 }
1027
1028 fn make_tool_call_with_id(id: &str, name: &str, args: serde_json::Value) -> ToolCall {
1029 ToolCall {
1030 id: id.to_string(),
1031 tool_type: "function".to_string(),
1032 function: FunctionCall {
1033 name: name.to_string(),
1034 arguments: args.to_string(),
1035 },
1036 }
1037 }
1038
1039 fn tool_event_context<'a>(
1040 call: &'a ToolCall,
1041 session_id: Option<&'a str>,
1042 root_session_id: Option<&'a str>,
1043 ) -> ToolExecutionContext<'a> {
1044 ToolExecutionContext {
1045 executing_supervisor: None,
1046 session_id,
1047 root_session_id,
1048 tool_call_id: &call.id,
1049 event_tx: None,
1050 available_tool_schemas: None,
1051 bypass_permissions: false,
1052 auto_approve_permissions: false,
1053 plan_read_only: false,
1054 can_async_resume: false,
1055 bash_completion_sink: None,
1056 pre_parsed_args: None,
1057 }
1058 }
1059
1060 fn assert_single_file_changed(
1061 recorder: &InMemoryToolEventRecorder,
1062 session_id: &str,
1063 root_session_id: &str,
1064 tool_name: &str,
1065 tool_call_id: &str,
1066 path: &str,
1067 ) {
1068 let events = recorder.try_snapshot().expect("snapshot tool events");
1069 assert_eq!(
1070 events.len(),
1071 1,
1072 "successful mutation must emit exactly once"
1073 );
1074 let event = &events[0];
1075 assert_eq!(event.context.session_id, session_id);
1076 assert_eq!(event.context.root_session_id, root_session_id);
1077 assert_eq!(event.context.tool_name, tool_name);
1078 assert_eq!(event.context.tool_call_id, tool_call_id);
1079 assert_eq!(
1080 event
1081 .file_changed_data()
1082 .expect("known file_changed event")
1083 .expect("valid file_changed payload")
1084 .path,
1085 path
1086 );
1087 }
1088
1089 fn seed_event(call_id: &str) -> ToolEventV1 {
1090 ToolEventV1::file_changed(
1091 ToolEventContextV1::bounded("seed-session", "seed-root-session", "Write", call_id)
1092 .unwrap(),
1093 FileChangedV1::bounded("/seed/file.txt").unwrap(),
1094 )
1095 .unwrap()
1096 }
1097
1098 fn make_tool_call_with_raw_args(name: &str, raw_args: &str) -> ToolCall {
1099 ToolCall {
1100 id: "call_1".to_string(),
1101 tool_type: "function".to_string(),
1102 function: FunctionCall {
1103 name: name.to_string(),
1104 arguments: raw_args.to_string(),
1105 },
1106 }
1107 }
1108
1109 struct ReturningPublisher(ToolEventPublishError);
1110
1111 impl ToolEventPublisher for ReturningPublisher {
1112 fn try_publish(&self, _event: ToolEventV1) -> Result<(), ToolEventPublishError> {
1113 Err(self.0.clone())
1114 }
1115 }
1116
1117 struct IsEnabledPanicPublisher;
1118
1119 impl ToolEventPublisher for IsEnabledPanicPublisher {
1120 fn is_enabled(&self) -> bool {
1121 panic!("is_enabled publisher panic")
1122 }
1123
1124 fn try_publish(&self, _event: ToolEventV1) -> Result<(), ToolEventPublishError> {
1125 unreachable!("disabled publisher must not receive an event")
1126 }
1127 }
1128
1129 struct TryPublishPanicPublisher;
1130
1131 impl ToolEventPublisher for TryPublishPanicPublisher {
1132 fn try_publish(&self, _event: ToolEventV1) -> Result<(), ToolEventPublishError> {
1133 panic!("try_publish publisher panic")
1134 }
1135 }
1136
1137 struct StubWriteTool {
1138 success: bool,
1139 }
1140
1141 #[async_trait]
1142 impl Tool for StubWriteTool {
1143 fn name(&self) -> &str {
1144 "Write"
1145 }
1146
1147 fn description(&self) -> &str {
1148 "test-only custom tool that deliberately spoofs Write"
1149 }
1150
1151 fn parameters_schema(&self) -> serde_json::Value {
1152 json!({"type": "object", "properties": {"file_path": {"type": "string"}}})
1153 }
1154
1155 async fn invoke(
1156 &self,
1157 _args: serde_json::Value,
1158 _ctx: ToolCtx,
1159 ) -> Result<ToolOutcome, ToolError> {
1160 Ok(ToolOutcome::Completed(ToolResult {
1161 success: self.success,
1162 result: "stub-write-result".to_string(),
1163 display_preference: None,
1164 images: Vec::new(),
1165 }))
1166 }
1167 }
1168
1169 fn marked_stub_write_executor(
1170 success: bool,
1171 publisher: Arc<dyn ToolEventPublisher>,
1172 ) -> BuiltinToolExecutor {
1173 let registry = ToolRegistry::new();
1174 let tool: Arc<dyn Tool> = Arc::new(StubWriteTool { success });
1175 registry
1176 .register_shared(tool.clone())
1177 .expect("register stub Write");
1178 BuiltinToolExecutor {
1179 registry,
1180 permission_checker: None,
1181 framework_builtin_tools: BTreeMap::from([("Write".to_string(), tool)]),
1182 tool_event_publisher: publisher,
1183 }
1184 }
1185
1186 async fn assert_real_write_succeeds_with_publisher(
1187 publisher: Arc<dyn ToolEventPublisher>,
1188 label: &str,
1189 ) {
1190 let dir = tempfile::tempdir().unwrap();
1191 let path = dir.path().join(format!("publisher-{label}.txt"));
1192 let call = make_tool_call_with_id(
1193 &format!("publisher-{label}"),
1194 "Write",
1195 json!({"file_path": path, "content": label}),
1196 );
1197 let executor = BuiltinToolExecutorBuilder::new()
1198 .with_filesystem_tool("Write")
1199 .expect("register built-in Write")
1200 .with_tool_event_publisher(publisher)
1201 .build();
1202
1203 let result = executor
1204 .execute_with_context(
1205 &call,
1206 tool_event_context(
1207 &call,
1208 Some("publisher-session"),
1209 Some("publisher-root-session"),
1210 ),
1211 )
1212 .await
1213 .expect("publisher behavior must not turn tool success into an error");
1214
1215 assert!(
1216 result.success,
1217 "publisher must not alter ToolResult.success"
1218 );
1219 assert_eq!(fs::read_to_string(path).await.unwrap(), label);
1220 }
1221
1222 fn make_executor(
1223 permission_checker: Option<Arc<dyn PermissionChecker>>,
1224 ) -> BuiltinToolExecutor {
1225 let builder = BuiltinToolExecutorBuilder::new()
1226 .with_tool(WriteTool::new())
1227 .expect("register Write tool");
1228
1229 let builder = match permission_checker {
1230 Some(checker) => builder.with_permission_checker(checker),
1231 None => builder,
1232 };
1233
1234 builder.build()
1235 }
1236
1237 async fn permission_request_payload(
1238 executor: &BuiltinToolExecutor,
1239 session_id: &str,
1240 args: serde_json::Value,
1241 ) -> serde_json::Value {
1242 let (event_tx, _event_rx) = mpsc::channel(4);
1243 let call = make_tool_call("Write", args);
1244 let ctx = ToolExecutionContext {
1245 executing_supervisor: None,
1246 session_id: Some(session_id),
1247 root_session_id: None,
1248 tool_call_id: &call.id,
1249 event_tx: Some(&event_tx),
1250 available_tool_schemas: None,
1251 bypass_permissions: false,
1252 auto_approve_permissions: false,
1253 plan_read_only: false,
1254 can_async_resume: false,
1255 bash_completion_sink: None,
1256 pre_parsed_args: None,
1257 };
1258 let result = executor
1259 .execute_with_context(&call, ctx)
1260 .await
1261 .expect("interactive permission gate should pause");
1262 serde_json::from_str(&result.result).expect("typed permission payload")
1263 }
1264
1265 struct RecordingApprovalProxy {
1266 requests: Arc<AtomicUsize>,
1267 approve: bool,
1268 }
1269
1270 #[async_trait]
1271 impl crate::approval::ApprovalProxy for RecordingApprovalProxy {
1272 async fn request_approval(&self, _ask: crate::approval::ApprovalAsk) -> bool {
1273 self.requests.fetch_add(1, Ordering::SeqCst);
1274 self.approve
1275 }
1276 }
1277
1278 #[test]
1279 fn test_normalize_tool_ref_accepts_claude_style_names() {
1280 assert_eq!(
1281 normalize_tool_ref("default::Bash"),
1282 Some("Bash".to_string())
1283 );
1284 }
1285
1286 #[test]
1287 fn test_normalize_tool_ref_accepts_legacy_camel_aliases() {
1288 assert_eq!(
1289 normalize_tool_ref("default::fileExists"),
1290 Some("FileExists".to_string())
1291 );
1292 assert_eq!(
1293 normalize_tool_ref("default::getCurrentDir"),
1294 Some("GetCurrentDir".to_string())
1295 );
1296 assert_eq!(
1297 normalize_tool_ref("default::getFileInfo"),
1298 Some("GetFileInfo".to_string())
1299 );
1300 assert_eq!(
1301 normalize_tool_ref("default::setWorkspace"),
1302 Some("SetWorkspace".to_string())
1303 );
1304 assert_eq!(
1305 normalize_tool_ref("default::sleep"),
1306 Some("Sleep".to_string())
1307 );
1308 }
1309
1310 #[test]
1311 fn test_normalize_tool_ref_accepts_legacy_snake_case_aliases() {
1312 assert_eq!(
1313 normalize_tool_ref("default::execute_command"),
1314 Some("Bash".to_string())
1315 );
1316 assert_eq!(
1317 normalize_tool_ref("default::file_exists"),
1318 Some("FileExists".to_string())
1319 );
1320 assert_eq!(
1321 normalize_tool_ref("default::get_current_dir"),
1322 Some("GetCurrentDir".to_string())
1323 );
1324 assert_eq!(
1325 normalize_tool_ref("default::get_file_info"),
1326 Some("GetFileInfo".to_string())
1327 );
1328 assert_eq!(
1329 normalize_tool_ref("default::list_directory"),
1330 Some("Glob".to_string())
1331 );
1332 assert_eq!(
1333 normalize_tool_ref("default::memory_note"),
1334 Some("memory_note".to_string())
1335 );
1336 assert_eq!(
1337 normalize_tool_ref("default::read_file"),
1338 Some("Read".to_string())
1339 );
1340 assert_eq!(
1341 normalize_tool_ref("default::set_workspace"),
1342 Some("SetWorkspace".to_string())
1343 );
1344 assert_eq!(
1345 normalize_tool_ref("default::write_file"),
1346 Some("Write".to_string())
1347 );
1348 }
1349
1350 #[test]
1351 fn test_normalize_tool_ref_accepts_spawn_task_aliases() {
1352 for alias in [
1353 "default::spawn_session",
1354 "default::sub_session",
1355 "default::sub_task",
1356 "default::team_agent",
1357 "default::child_session",
1358 ] {
1359 assert_eq!(normalize_tool_ref(alias), Some("SubAgent".to_string()));
1360 }
1361 }
1362
1363 #[test]
1364 fn test_normalize_tool_ref_accepts_server_overlay_tools() {
1365 assert_eq!(normalize_tool_ref("compress_context"), None);
1366 assert_eq!(
1367 normalize_tool_ref("default::read_skill_resource"),
1368 Some("read_skill_resource".to_string())
1369 );
1370 }
1371
1372 #[tokio::test]
1373 async fn test_executor_accepts_legacy_read_file_path_argument() {
1374 let dir = tempfile::tempdir().unwrap();
1375 let file_path = dir.path().join("legacy-read.txt");
1376 fs::write(&file_path, "legacy read content").await.unwrap();
1377
1378 let executor = BuiltinToolExecutor::new();
1379 let call = make_tool_call("read_file", json!({"path": file_path}));
1380
1381 let result = executor.execute(&call).await.unwrap();
1382 assert!(result.success);
1383 assert!(result.result.contains("legacy read content"));
1384 }
1385
1386 #[tokio::test]
1387 async fn test_executor_accepts_legacy_list_directory_without_pattern() {
1388 let dir = tempfile::tempdir().unwrap();
1389 let file_path = dir.path().join("legacy-list.txt");
1390 fs::write(&file_path, "legacy list content").await.unwrap();
1391
1392 let executor = BuiltinToolExecutor::new();
1393 let call = make_tool_call("list_directory", json!({"path": dir.path()}));
1394
1395 let result = executor.execute(&call).await.unwrap();
1396 assert!(result.success);
1397 assert!(result.result.contains("legacy-list.txt"));
1398 }
1399
1400 #[tokio::test]
1401 async fn test_executor_accepts_canonical_read_with_path_argument() {
1402 let dir = tempfile::tempdir().unwrap();
1403 let file_path = dir.path().join("canonical-read.txt");
1404 fs::write(&file_path, "canonical read content")
1405 .await
1406 .unwrap();
1407
1408 let executor = BuiltinToolExecutor::new();
1409 let call = make_tool_call("Read", json!({"path": file_path}));
1410
1411 let result = executor.execute(&call).await.unwrap();
1412 assert!(result.success);
1413 assert!(result.result.contains("canonical read content"));
1414
1415 let namespaced = make_tool_call("default::Read", json!({"path": file_path}));
1416 let result = executor.execute(&namespaced).await.unwrap();
1417 assert!(result.success);
1418 assert!(result.result.contains("canonical read content"));
1419 }
1420
1421 #[tokio::test]
1422 async fn test_executor_accepts_canonical_glob_without_pattern_when_path_present() {
1423 let dir = tempfile::tempdir().unwrap();
1424 let file_path = dir.path().join("canonical-list.txt");
1425 fs::write(&file_path, "canonical list content")
1426 .await
1427 .unwrap();
1428
1429 let executor = BuiltinToolExecutor::new();
1430 let call = make_tool_call("Glob", json!({"path": dir.path()}));
1431
1432 let result = executor.execute(&call).await.unwrap();
1433 assert!(result.success);
1434 assert!(result.result.contains("canonical-list.txt"));
1435 }
1436
1437 #[test]
1438 fn test_executor_workspace_mutability_depends_on_path_argument() {
1439 let executor = BuiltinToolExecutor::new();
1440 let get_call = make_tool_call("Workspace", json!({}));
1441 let set_call = make_tool_call("Workspace", json!({"path": "/tmp"}));
1442
1443 assert_eq!(
1444 executor.call_mutability(&get_call),
1445 crate::ToolMutability::ReadOnly
1446 );
1447 assert!(executor.call_concurrency_safe(&get_call));
1448
1449 assert_eq!(
1450 executor.call_mutability(&set_call),
1451 crate::ToolMutability::Mutating
1452 );
1453 assert!(!executor.call_concurrency_safe(&set_call));
1454 }
1455
1456 #[test]
1457 fn call_parallel_classification_matches_individual_methods() {
1458 let executor = BuiltinToolExecutor::new();
1466 let cases: &[(&str, serde_json::Value)] = &[
1467 ("Read", json!({})),
1468 ("Grep", json!({"pattern": "x"})),
1469 (
1470 "Write",
1471 json!({"file_path": "/tmp/par_cls.txt", "content": "y"}),
1472 ),
1473 ("Bash", json!({"command": "echo hi"})),
1474 ("Workspace", json!({})),
1475 ("Workspace", json!({"path": "/tmp"})),
1476 ];
1477
1478 for (name, args) in cases {
1479 let call = make_tool_call(name, args.clone());
1480 let expected_mutability = executor.call_mutability(&call);
1481 let expected_concurrency = executor.call_concurrency_safe(&call);
1482 let (mutability, concurrency) = executor.call_parallel_classification(&call);
1483 assert_eq!(
1484 mutability, expected_mutability,
1485 "mutability mismatch for {name} ({args})"
1486 );
1487 assert_eq!(
1488 concurrency, expected_concurrency,
1489 "concurrency mismatch for {name} ({args})"
1490 );
1491 }
1492 }
1493
1494 #[test]
1495 fn list_tools_snapshot_is_stable_across_calls() {
1496 let executor = BuiltinToolExecutor::new();
1501 let first: Vec<String> = executor
1502 .list_tools()
1503 .into_iter()
1504 .map(|s| s.function.name)
1505 .collect();
1506 let second: Vec<String> = executor
1507 .list_tools()
1508 .into_iter()
1509 .map(|s| s.function.name)
1510 .collect();
1511 assert!(!first.is_empty(), "builtin executor should expose tools");
1512 assert_eq!(
1513 first, second,
1514 "list_tools() must be deterministic per round"
1515 );
1516 }
1517
1518 #[tokio::test]
1519 async fn test_executor_recovers_truncated_json_arguments() {
1520 let dir = tempfile::tempdir().unwrap();
1521 let path = dir.path().join("recovered-write.txt");
1522
1523 let malformed_args = format!(
1525 r#"{{"file_path":"{}","content":"recovered content""#,
1526 path.display()
1527 );
1528
1529 let executor = BuiltinToolExecutor::new();
1530 let call = make_tool_call_with_raw_args("Write", &malformed_args);
1531
1532 let result = executor
1533 .execute(&call)
1534 .await
1535 .expect("truncated JSON should be auto-repaired");
1536 assert!(result.success);
1537
1538 let written = fs::read_to_string(&path)
1539 .await
1540 .expect("file should be written");
1541 assert_eq!(written, "recovered content");
1542 }
1543
1544 #[test]
1545 fn test_normalize_tool_ref_rejects_unknown_tool() {
1546 assert_eq!(normalize_tool_ref("default::search"), None);
1547 }
1548
1549 #[test]
1550 fn test_executor_does_not_expose_legacy_tools() {
1551 let executor = BuiltinToolExecutor::new();
1552 let tool_names: Vec<String> = executor
1553 .list_tools()
1554 .into_iter()
1555 .map(|schema| schema.function.name)
1556 .collect();
1557
1558 for legacy in ["claude_code", "search_in_file", "search_in_project"] {
1559 assert!(!tool_names.iter().any(|name| name == legacy));
1560 }
1561 }
1562
1563 #[test]
1564 fn test_critical_tool_schemas_match_claude_shapes() {
1565 let executor = BuiltinToolExecutor::new();
1566 let tools = executor.list_tools();
1567
1568 let get_params = |name: &str| {
1569 tools
1570 .iter()
1571 .find(|tool| tool.function.name == name)
1572 .unwrap()
1573 .function
1574 .parameters
1575 .clone()
1576 };
1577
1578 let grep = get_params("Grep");
1579 assert_eq!(grep["required"], json!(["pattern"]));
1580 assert_eq!(
1581 grep["properties"]["output_mode"]["enum"],
1582 json!(["content", "files_with_matches", "count"])
1583 );
1584 assert!(grep["properties"]["-A"].is_object());
1585 assert!(grep["properties"]["-B"].is_object());
1586 assert!(grep["properties"]["-C"].is_object());
1587 assert!(grep["properties"]["-n"].is_object());
1588 assert!(grep["properties"]["-i"].is_object());
1589
1590 let edit = get_params("Edit");
1591 assert_eq!(edit["required"], json!(["file_path"]));
1592 assert_eq!(edit["properties"]["old_string"]["type"], "string");
1593 assert_eq!(edit["properties"]["new_string"]["type"], "string");
1594 assert_eq!(edit["properties"]["patch"]["type"], "string");
1595 assert_eq!(edit["properties"]["replace_all"]["type"], "boolean");
1596 assert!(edit.get("oneOf").is_none());
1597
1598 assert_eq!(edit["properties"]["patch"]["type"], "string");
1601 assert_eq!(edit["properties"]["line_number"]["type"], "integer");
1602
1603 let bash = get_params("Bash");
1604 assert_eq!(bash["required"], json!(["command"]));
1605 assert_eq!(bash["properties"]["run_in_background"]["type"], "boolean");
1606 assert_eq!(bash["properties"]["workdir"]["type"], "string");
1607
1608 let bash_output = get_params("BashOutput");
1609 assert_eq!(bash_output["required"], json!(["bash_id"]));
1610 assert_eq!(bash_output["properties"]["filter"]["type"], "string");
1611 }
1612
1613 #[test]
1614 fn test_tool_schemas_avoid_openai_forbidden_top_level_keywords() {
1615 let executor = BuiltinToolExecutor::new();
1616 let tools = executor.list_tools();
1617 let forbidden = ["oneOf", "anyOf", "allOf", "not", "enum"];
1618
1619 for tool in tools {
1620 let params = &tool.function.parameters;
1621 assert_eq!(
1622 params["type"], "object",
1623 "tool '{}' parameters must be a top-level object schema",
1624 tool.function.name
1625 );
1626 for key in forbidden {
1627 assert!(
1628 params.get(key).is_none(),
1629 "tool '{}' parameters contains forbidden top-level keyword '{}'",
1630 tool.function.name,
1631 key
1632 );
1633 }
1634 }
1635 }
1636
1637 #[test]
1638 fn test_executor_has_all_builtin_tools() {
1639 let executor = BuiltinToolExecutor::new();
1640 let tools = executor.list_tools();
1641
1642 assert_eq!(tools.len(), BUILTIN_TOOL_NAMES.len());
1643
1644 let tool_names: Vec<String> = tools.iter().map(|t| t.function.name.clone()).collect();
1645 for tool_name in BUILTIN_TOOL_NAMES {
1646 assert!(tool_names.contains(&tool_name.to_string()));
1647 }
1648 }
1649
1650 #[test]
1651 fn test_executor_builds_enhanced_prompt() {
1652 let executor = BuiltinToolExecutor::new();
1653 let prompt = executor.build_enhanced_prompt(GuideBuildContext::default());
1654 assert!(prompt.contains("## Tool Usage Guidelines"));
1655 assert!(prompt.contains("**Read**"));
1656 }
1657
1658 #[test]
1659 fn test_executor_builder_empty() {
1660 let executor = BuiltinToolExecutorBuilder::new().build();
1661 assert!(executor.list_tools().is_empty());
1662 }
1663
1664 #[test]
1665 fn test_executor_builder_with_default_tools() {
1666 let executor = BuiltinToolExecutorBuilder::new()
1667 .with_default_tools()
1668 .build();
1669 assert_eq!(executor.list_tools().len(), BUILTIN_TOOL_NAMES.len());
1670 }
1671
1672 #[test]
1673 fn test_executor_builder_with_specific_tool() {
1674 let executor = BuiltinToolExecutorBuilder::new()
1675 .with_filesystem_tool("Read")
1676 .unwrap()
1677 .build();
1678
1679 let tools = executor.list_tools();
1680 assert_eq!(tools.len(), 1);
1681 assert_eq!(tools[0].function.name, "Read");
1682 }
1683
1684 #[tokio::test]
1685 async fn test_executor_skips_permission_checks_without_checker() {
1686 let executor = make_executor(None);
1687 let path = "/tmp/executor_permission_none.txt";
1688 let _ = fs::remove_file(path).await;
1689
1690 let call = make_tool_call("Write", json!({"file_path": path, "content": "ok"}));
1691 let result = executor.execute(&call).await.expect("execute tool");
1692
1693 assert!(result.success);
1694 let _ = fs::remove_file(path).await;
1695 }
1696
1697 #[tokio::test]
1698 async fn test_executor_with_permission_checker_enforces_checks() {
1699 let checker = Arc::new(crate::permission::DenyDangerousPermissionChecker);
1700 let executor = make_executor(Some(checker));
1701 let path = "/tmp/executor_permission_denied.txt";
1702 let _ = fs::remove_file(path).await;
1703
1704 let call = make_tool_call("Write", json!({"file_path": path, "content": "nope"}));
1705 let result = executor.execute(&call).await;
1706
1707 assert!(matches!(result, Err(ToolError::Execution(_))));
1708 assert!(fs::metadata(path).await.is_err());
1709 }
1710
1711 #[tokio::test]
1712 async fn test_bypass_permissions_skips_checker() {
1713 let config = Arc::new(crate::permission::PermissionConfig::new());
1719 let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
1720 let executor = BuiltinToolExecutorBuilder::new()
1721 .with_tool(BashTool::new())
1722 .expect("register Bash tool")
1723 .with_permission_checker(checker)
1724 .build();
1725 let dir = tempfile::tempdir().unwrap();
1726 let path = dir.path().join("bypass_allows_bash.txt");
1727 let command = format!("printf ordinary > {}", path.display());
1728 let approval_requests = Arc::new(AtomicUsize::new(0));
1729 let proxy: Arc<dyn crate::approval::ApprovalProxy> = Arc::new(RecordingApprovalProxy {
1730 requests: approval_requests.clone(),
1731 approve: true,
1732 });
1733 let (event_tx, mut event_rx) = mpsc::channel(8);
1734
1735 let call = make_tool_call("Bash", json!({"command": command}));
1736 let ctx = ToolExecutionContext {
1737 executing_supervisor: None,
1738 session_id: Some("s-bypass"),
1739 root_session_id: None,
1740 tool_call_id: &call.id,
1741 event_tx: Some(&event_tx),
1742 available_tool_schemas: None,
1743 bypass_permissions: true,
1744 auto_approve_permissions: false,
1745 plan_read_only: false,
1746 can_async_resume: false,
1747 bash_completion_sink: None,
1748 pre_parsed_args: None,
1749 };
1750 let result = crate::approval::with_approval_proxy(
1751 Some(proxy),
1752 executor.execute_with_context(&call, ctx),
1753 )
1754 .await;
1755
1756 assert!(result.is_ok(), "bypass should allow the write: {result:?}");
1757 assert_eq!(fs::read_to_string(&path).await.unwrap(), "ordinary");
1758 assert_eq!(
1759 approval_requests.load(Ordering::SeqCst),
1760 0,
1761 "ordinary bypassed child command must not invoke the parent reviewer"
1762 );
1763 assert!(
1764 event_rx.try_recv().is_err(),
1765 "ordinary bypassed child command must not emit a human approval event"
1766 );
1767 }
1768
1769 #[tokio::test]
1770 async fn hook_allow_skips_configured_ask_for_exact_call() {
1771 let dir = tempfile::tempdir().unwrap();
1772 let path = dir.path().join("hook-allowed.txt");
1773 let path_str = path.to_str().unwrap().to_string();
1774 let config = Arc::new(crate::permission::PermissionConfig::new());
1775 config.set_ask_rules([format!("Write({}/**)", dir.path().to_str().unwrap())]);
1776 let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
1777 let executor = make_executor(Some(checker));
1778 let call = make_tool_call(
1779 "Write",
1780 json!({"file_path": path_str, "content": "allowed by hook"}),
1781 );
1782 let ctx = ToolExecutionContext {
1783 executing_supervisor: None,
1784 session_id: Some("s-hook-allow"),
1785 root_session_id: None,
1786 tool_call_id: &call.id,
1787 event_tx: None,
1788 available_tool_schemas: None,
1789 bypass_permissions: false,
1790 auto_approve_permissions: false,
1791 plan_read_only: false,
1792 can_async_resume: false,
1793 bash_completion_sink: None,
1794 pre_parsed_args: None,
1795 };
1796
1797 let result = crate::with_hook_permission_override(
1798 Some(crate::HookPermissionOverride::Allow),
1799 &call.id,
1800 executor.execute_with_context(&call, ctx),
1801 )
1802 .await;
1803
1804 assert!(
1805 result.is_ok(),
1806 "hook allow should skip ordinary ask: {result:?}"
1807 );
1808 assert_eq!(fs::read_to_string(path).await.unwrap(), "allowed by hook");
1809 assert_eq!(
1810 crate::current_hook_permission_override(&call.id),
1811 None,
1812 "the one-call override must not leak"
1813 );
1814 }
1815
1816 #[tokio::test]
1817 async fn hook_allow_cannot_skip_hard_dangerous_parent_review() {
1818 let config = Arc::new(crate::permission::PermissionConfig::new());
1819 let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
1820 let executor = BuiltinToolExecutorBuilder::new()
1821 .with_tool(BashTool::new())
1822 .expect("register Bash tool")
1823 .with_permission_checker(checker)
1824 .build();
1825 let dir = tempfile::tempdir().unwrap();
1826 let path = dir.path().join("hard-dangerous-must-not-run.txt");
1827 let command = format!("eval 'printf denied > {}'", path.display());
1828 let requests = Arc::new(AtomicUsize::new(0));
1829 let proxy: Arc<dyn crate::approval::ApprovalProxy> = Arc::new(RecordingApprovalProxy {
1830 requests: requests.clone(),
1831 approve: false,
1832 });
1833 let call = make_tool_call("Bash", json!({"command": command}));
1834 let ctx = ToolExecutionContext {
1835 executing_supervisor: None,
1836 session_id: Some("s-hook-hard-dangerous"),
1837 root_session_id: None,
1838 tool_call_id: &call.id,
1839 event_tx: None,
1840 available_tool_schemas: None,
1841 bypass_permissions: true,
1842 auto_approve_permissions: false,
1843 plan_read_only: false,
1844 can_async_resume: false,
1845 bash_completion_sink: None,
1846 pre_parsed_args: None,
1847 };
1848
1849 let result = crate::with_hook_permission_override(
1850 Some(crate::HookPermissionOverride::Allow),
1851 &call.id,
1852 crate::approval::with_approval_proxy(
1853 Some(proxy),
1854 executor.execute_with_context(&call, ctx),
1855 ),
1856 )
1857 .await;
1858
1859 assert!(
1860 matches!(result, Err(ToolError::Execution(ref message)) if message.contains("denied by host")),
1861 "hard-dangerous review must remain authoritative: {result:?}"
1862 );
1863 assert_eq!(requests.load(Ordering::SeqCst), 1);
1864 assert!(!path.exists());
1865 }
1866
1867 #[tokio::test]
1868 async fn hook_allow_cannot_skip_explicit_deny() {
1869 let dir = tempfile::tempdir().unwrap();
1870 let path = dir.path().join("explicit-deny.txt");
1871 let path_str = path.to_str().unwrap().to_string();
1872 let config = Arc::new(crate::permission::PermissionConfig::new());
1873 config.deny_scoped_session_permission(
1874 "s-hook-explicit-deny",
1875 crate::permission::PermissionType::WriteFile,
1876 path_str.clone(),
1877 );
1878 let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
1879 let executor = make_executor(Some(checker));
1880 let call = make_tool_call(
1881 "Write",
1882 json!({"file_path": path_str, "content": "must not be written"}),
1883 );
1884 let ctx = ToolExecutionContext {
1885 executing_supervisor: None,
1886 session_id: Some("s-hook-explicit-deny"),
1887 root_session_id: None,
1888 tool_call_id: &call.id,
1889 event_tx: None,
1890 available_tool_schemas: None,
1891 bypass_permissions: false,
1892 auto_approve_permissions: false,
1893 plan_read_only: false,
1894 can_async_resume: false,
1895 bash_completion_sink: None,
1896 pre_parsed_args: None,
1897 };
1898
1899 let result = crate::with_hook_permission_override(
1900 Some(crate::HookPermissionOverride::Allow),
1901 &call.id,
1902 executor.execute_with_context(&call, ctx),
1903 )
1904 .await;
1905
1906 assert!(
1907 matches!(result, Err(ToolError::Execution(ref message)) if message.contains("remembered session decision")),
1908 "explicit deny must remain authoritative: {result:?}"
1909 );
1910 assert!(!path.exists());
1911 }
1912
1913 #[tokio::test]
1914 async fn test_forced_ask_rule_overrides_bypass() {
1915 let config = Arc::new(crate::permission::PermissionConfig::new());
1919 let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
1920 let executor = BuiltinToolExecutorBuilder::new()
1921 .with_tool(BashTool::new())
1922 .expect("register Bash tool")
1923 .with_permission_checker(checker)
1924 .build();
1925 let dir = tempfile::tempdir().unwrap();
1926 let denied_path = dir.path().join("forced-denied.txt");
1927 let denied_command = format!("eval 'printf denied > {}'", denied_path.display());
1928 let denied_requests = Arc::new(AtomicUsize::new(0));
1929 let deny_proxy: Arc<dyn crate::approval::ApprovalProxy> =
1930 Arc::new(RecordingApprovalProxy {
1931 requests: denied_requests.clone(),
1932 approve: false,
1933 });
1934
1935 let denied_call = make_tool_call("Bash", json!({"command": denied_command}));
1936 let denied_ctx = ToolExecutionContext {
1937 executing_supervisor: None,
1938 session_id: Some("s-forced"),
1939 root_session_id: None,
1940 tool_call_id: &denied_call.id,
1941 event_tx: None,
1942 available_tool_schemas: None,
1943 bypass_permissions: true,
1944 auto_approve_permissions: false,
1945 plan_read_only: false,
1946 can_async_resume: false,
1947 bash_completion_sink: None,
1948 pre_parsed_args: None,
1949 };
1950 let denied = crate::approval::with_approval_proxy(
1951 Some(deny_proxy),
1952 executor.execute_with_context(&denied_call, denied_ctx),
1953 )
1954 .await;
1955
1956 assert!(
1957 matches!(denied, Err(ToolError::Execution(ref message)) if message.contains("denied by host")),
1958 "parent denial must block forced-ask execution under bypass: {denied:?}"
1959 );
1960 assert_eq!(denied_requests.load(Ordering::SeqCst), 1);
1961 assert!(!denied_path.exists(), "denied command must not execute");
1962
1963 let approved_path = dir.path().join("forced-approved.txt");
1964 let approved_command = format!("eval 'printf approved > {}'", approved_path.display());
1965 let approved_requests = Arc::new(AtomicUsize::new(0));
1966 let approve_proxy: Arc<dyn crate::approval::ApprovalProxy> =
1967 Arc::new(RecordingApprovalProxy {
1968 requests: approved_requests.clone(),
1969 approve: true,
1970 });
1971 let approved_call = make_tool_call("Bash", json!({"command": approved_command}));
1972 let approved_ctx = ToolExecutionContext {
1973 executing_supervisor: None,
1974 session_id: Some("s-forced"),
1975 root_session_id: None,
1976 tool_call_id: &approved_call.id,
1977 event_tx: None,
1978 available_tool_schemas: None,
1979 bypass_permissions: true,
1980 auto_approve_permissions: false,
1981 plan_read_only: false,
1982 can_async_resume: false,
1983 bash_completion_sink: None,
1984 pre_parsed_args: None,
1985 };
1986 let approved = crate::approval::with_approval_proxy(
1987 Some(approve_proxy),
1988 executor.execute_with_context(&approved_call, approved_ctx),
1989 )
1990 .await;
1991
1992 assert!(
1993 approved.is_ok(),
1994 "parent approval must allow forced-ask execution under bypass: {approved:?}"
1995 );
1996 assert_eq!(approved_requests.load(Ordering::SeqCst), 1);
1997 assert_eq!(fs::read_to_string(approved_path).await.unwrap(), "approved");
1998 }
1999
2000 #[tokio::test]
2001 async fn auto_executes_forced_ask_without_proxy_or_human_event() {
2002 let config = Arc::new(crate::permission::PermissionConfig::new());
2003 let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
2004 let executor = BuiltinToolExecutorBuilder::new()
2005 .with_tool(BashTool::new())
2006 .expect("register Bash tool")
2007 .with_permission_checker(checker)
2008 .build();
2009 let dir = tempfile::tempdir().unwrap();
2010 let path = dir.path().join("auto-forced.txt");
2011 let command = format!("eval 'printf auto > {}'", path.display());
2012 let approval_requests = Arc::new(AtomicUsize::new(0));
2013 let proxy: Arc<dyn crate::approval::ApprovalProxy> = Arc::new(RecordingApprovalProxy {
2014 requests: approval_requests.clone(),
2015 approve: false,
2016 });
2017 let (event_tx, mut event_rx) = mpsc::channel(8);
2018 let call = make_tool_call("Bash", json!({"command": command}));
2019 let ctx = ToolExecutionContext {
2020 executing_supervisor: None,
2021 session_id: Some("s-auto"),
2022 root_session_id: None,
2023 tool_call_id: &call.id,
2024 event_tx: Some(&event_tx),
2025 available_tool_schemas: None,
2026 bypass_permissions: false,
2027 auto_approve_permissions: true,
2028 plan_read_only: false,
2029 can_async_resume: false,
2030 bash_completion_sink: None,
2031 pre_parsed_args: None,
2032 };
2033
2034 let result = crate::approval::with_approval_proxy(
2035 Some(proxy),
2036 executor.execute_with_context(&call, ctx),
2037 )
2038 .await;
2039
2040 assert!(result.is_ok(), "Auto should execute directly: {result:?}");
2041 assert_eq!(fs::read_to_string(path).await.unwrap(), "auto");
2042 assert_eq!(approval_requests.load(Ordering::SeqCst), 0);
2043 assert!(
2044 event_rx.try_recv().is_err(),
2045 "Auto must not emit an interactive approval request"
2046 );
2047 }
2048
2049 #[tokio::test]
2050 async fn auto_never_overrides_guardian_read_only_hard_deny() {
2051 let config = Arc::new(crate::permission::PermissionConfig::new());
2052 let base: Arc<dyn crate::permission::PermissionChecker> = Arc::new(
2053 crate::permission::ConfigPermissionChecker::new(config.clone()),
2054 );
2055 let checker = Arc::new(crate::permission::GuardianReadOnlyChecker::new(base));
2056 let executor = BuiltinToolExecutorBuilder::new()
2057 .with_tool(BashTool::new())
2058 .expect("register Bash tool")
2059 .with_permission_checker(checker)
2060 .build();
2061 let dir = tempfile::tempdir().unwrap();
2062 let path = dir.path().join("guardian-mutation.txt");
2063 let command = format!("printf blocked > {}", path.display());
2064 let call = make_tool_call("Bash", json!({"command": command}));
2065 let ctx = ToolExecutionContext {
2066 executing_supervisor: None,
2067 session_id: Some("guardian-auto"),
2068 root_session_id: None,
2069 tool_call_id: &call.id,
2070 event_tx: None,
2071 available_tool_schemas: None,
2072 bypass_permissions: false,
2073 auto_approve_permissions: true,
2074 plan_read_only: false,
2075 can_async_resume: false,
2076 bash_completion_sink: None,
2077 pre_parsed_args: None,
2078 };
2079
2080 let error = executor
2081 .execute_with_context(&call, ctx)
2082 .await
2083 .expect_err("Auto must retain Guardian read-only authority");
2084
2085 assert!(error.to_string().contains("Guardian reviewer is read-only"));
2086 assert!(!path.exists());
2087 }
2088
2089 #[tokio::test]
2090 async fn test_explicit_deny_overrides_bypass() {
2091 let dir = tempfile::tempdir().unwrap();
2092 let path = dir.path().join("explicit-deny.txt");
2093 let path_str = path.to_str().unwrap();
2094 let config = Arc::new(crate::permission::PermissionConfig::new());
2095 config.add_rule(crate::permission::PermissionRule::new(
2096 crate::permission::PermissionType::WriteFile,
2097 path_str,
2098 false,
2099 ));
2100 let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
2101 let executor = make_executor(Some(checker));
2102 let call = make_tool_call(
2103 "Write",
2104 json!({"file_path": path_str, "content": "blocked"}),
2105 );
2106 let ctx = ToolExecutionContext {
2107 executing_supervisor: None,
2108 session_id: Some("s-explicit-deny"),
2109 root_session_id: None,
2110 tool_call_id: &call.id,
2111 event_tx: None,
2112 available_tool_schemas: None,
2113 bypass_permissions: true,
2114 auto_approve_permissions: false,
2115 plan_read_only: false,
2116 can_async_resume: false,
2117 bash_completion_sink: None,
2118 pre_parsed_args: None,
2119 };
2120
2121 let result = executor.execute_with_context(&call, ctx).await;
2122 assert!(
2123 matches!(result, Err(ToolError::Execution(ref message)) if message.contains("explicit policy")),
2124 "explicit deny must beat bypass: {result:?}"
2125 );
2126 assert!(!path.exists());
2127 }
2128
2129 #[tokio::test]
2130 async fn test_explicit_delete_deny_overrides_bypass() {
2131 let config = Arc::new(crate::permission::PermissionConfig::new());
2132 config.add_rule(crate::permission::PermissionRule::new(
2133 crate::permission::PermissionType::DeleteOperation,
2134 "rm child-to-preserve",
2135 false,
2136 ));
2137 let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
2138 let executor = BuiltinToolExecutorBuilder::new()
2139 .with_tool(BashTool::new())
2140 .expect("register Bash tool")
2141 .with_permission_checker(checker)
2142 .build();
2143 let call = make_tool_call("Bash", json!({"command": "rm child-to-preserve"}));
2144 let ctx = ToolExecutionContext {
2145 executing_supervisor: None,
2146 session_id: Some("s-explicit-delete-deny"),
2147 root_session_id: None,
2148 tool_call_id: &call.id,
2149 event_tx: None,
2150 available_tool_schemas: None,
2151 bypass_permissions: true,
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 result = executor.execute_with_context(&call, ctx).await;
2160 assert!(
2161 matches!(result, Err(ToolError::Execution(ref message)) if message.contains("explicit policy")),
2162 "explicit delete deny must beat bypass: {result:?}"
2163 );
2164 }
2165
2166 #[tokio::test]
2167 async fn plan_auto_denies_mutation_but_allows_read_without_a_checker() {
2168 let executor = BuiltinToolExecutor::new();
2169 let dir = tempfile::tempdir().unwrap();
2170 let path = dir.path().join("plan-auto.txt");
2171 let write = make_tool_call(
2172 "Write",
2173 json!({"file_path": path, "content": "must not run"}),
2174 );
2175 let write_ctx = ToolExecutionContext {
2176 executing_supervisor: None,
2177 session_id: Some("plan-auto"),
2178 root_session_id: None,
2179 tool_call_id: &write.id,
2180 event_tx: None,
2181 available_tool_schemas: None,
2182 bypass_permissions: false,
2183 auto_approve_permissions: true,
2184 plan_read_only: true,
2185 can_async_resume: false,
2186 bash_completion_sink: None,
2187 pre_parsed_args: None,
2188 };
2189 let denied = executor.execute_with_context(&write, write_ctx).await;
2190 assert!(matches!(
2191 denied,
2192 Err(ToolError::Execution(ref message)) if message.contains("Plan mode")
2193 ));
2194 assert!(tokio::fs::metadata(&path).await.is_err());
2195
2196 tokio::fs::write(&path, "readable").await.unwrap();
2197 let read = make_tool_call("Read", json!({"file_path": path}));
2198 let read_ctx = ToolExecutionContext {
2199 executing_supervisor: None,
2200 session_id: Some("plan-auto"),
2201 root_session_id: None,
2202 tool_call_id: &read.id,
2203 event_tx: None,
2204 available_tool_schemas: None,
2205 bypass_permissions: false,
2206 auto_approve_permissions: true,
2207 plan_read_only: true,
2208 can_async_resume: false,
2209 bash_completion_sink: None,
2210 pre_parsed_args: None,
2211 };
2212 let allowed = executor
2213 .execute_with_context(&read, read_ctx)
2214 .await
2215 .unwrap();
2216 assert!(allowed.success);
2217 }
2218
2219 #[tokio::test]
2220 async fn auto_request_permissions_fails_without_creating_a_pause() {
2221 let executor = BuiltinToolExecutor::new();
2222 let (event_tx, mut event_rx) = mpsc::channel(4);
2223 let call = make_tool_call("request_permissions", json!({}));
2224 let ctx = ToolExecutionContext {
2225 executing_supervisor: None,
2226 session_id: Some("auto-no-prompt"),
2227 root_session_id: None,
2228 tool_call_id: &call.id,
2229 event_tx: Some(&event_tx),
2230 available_tool_schemas: None,
2231 bypass_permissions: false,
2232 auto_approve_permissions: true,
2233 plan_read_only: false,
2234 can_async_resume: false,
2235 bash_completion_sink: None,
2236 pre_parsed_args: None,
2237 };
2238
2239 let result = executor.execute_with_context_outcome(&call, ctx).await;
2240 assert!(matches!(
2241 result,
2242 Err(ToolError::Execution(ref message)) if message.contains("cannot request expanded permissions")
2243 ));
2244 assert!(event_rx.try_recv().is_err());
2245 }
2246
2247 #[tokio::test]
2248 async fn interactive_gate_returns_synthesized_approval_pause() {
2249 let config = Arc::new(crate::permission::PermissionConfig::new());
2257 config.set_ask_rules(["Write(/etc/**)".to_string()]);
2258 config.register_session_workspace("s-interactive", "/workspace/project");
2259 let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
2260 let executor = make_executor(Some(checker));
2261
2262 let (tx, mut rx) = mpsc::channel(8);
2263 let call = make_tool_call(
2264 "Write",
2265 json!({"file_path": "/etc/gated.conf", "content": "x"}),
2266 );
2267 let ctx = ToolExecutionContext {
2268 executing_supervisor: None,
2269 session_id: Some("s-interactive"),
2270 root_session_id: None,
2271 tool_call_id: &call.id,
2272 event_tx: Some(&tx),
2273 available_tool_schemas: None,
2274 bypass_permissions: false,
2275 auto_approve_permissions: false,
2276 plan_read_only: false,
2277 can_async_resume: false,
2278 bash_completion_sink: None,
2279 pre_parsed_args: None,
2280 };
2281
2282 let result = executor
2283 .execute_with_context(&call, ctx)
2284 .await
2285 .expect("interactive gate should pause (Ok), not error");
2286
2287 assert_eq!(
2288 result.display_preference.as_deref(),
2289 Some("request_permissions"),
2290 "interactive gate must return the request_permissions pause result"
2291 );
2292 assert!(result.result.contains("awaiting_permission_approval"));
2293 let payload: serde_json::Value = serde_json::from_str(&result.result).expect("payload");
2294 let request = &payload["permission_request"];
2295 assert_eq!(request["request_id"], call.id);
2296 assert_eq!(request["session_id"], "s-interactive");
2297 assert_eq!(request["workspace_path"], "/workspace/project");
2298 assert_eq!(request["reason_code"], "configured_always_ask");
2299 assert_eq!(
2300 request["allowed_decisions"],
2301 json!(["allow_once", "deny_once"])
2302 );
2303 assert_eq!(payload["options"], json!(["Approve", "Deny"]));
2304 assert!(fs::metadata("/etc/gated.conf").await.is_err());
2305
2306 let ev = rx.recv().await.expect("approval event should be emitted");
2307 assert!(
2308 matches!(ev, AgentEvent::ToolApprovalRequested { tool_name, .. } if tool_name == "Write")
2309 );
2310 }
2311
2312 #[tokio::test]
2313 async fn proactive_permission_batch_uses_typed_remembered_scopes_then_completes() {
2314 let config = Arc::new(crate::permission::PermissionConfig::new());
2315 config.set_session_workspace("proactive-session", Some("/workspace/project".to_string()));
2316 let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(
2317 config.clone(),
2318 ));
2319 let executor = BuiltinToolExecutorBuilder::new()
2320 .with_tool(crate::tools::RequestPermissionsTool::new())
2321 .expect("register request_permissions")
2322 .with_permission_checker(checker)
2323 .build();
2324 let call = make_tool_call(
2325 "request_permissions",
2326 json!({
2327 "reason": "Deploy the service",
2328 "permissions": [
2329 {
2330 "type": "execute_command",
2331 "resource": "docker compose up -d"
2332 },
2333 {
2334 "type": "http_request",
2335 "resource": "registry.example.com"
2336 }
2337 ]
2338 }),
2339 );
2340 let (event_tx, _event_rx) = mpsc::channel(8);
2341
2342 let first = executor
2343 .execute_with_context(
2344 &call,
2345 ToolExecutionContext {
2346 executing_supervisor: None,
2347 session_id: Some("proactive-session"),
2348 root_session_id: None,
2349 tool_call_id: &call.id,
2350 event_tx: Some(&event_tx),
2351 available_tool_schemas: None,
2352 bypass_permissions: false,
2353 auto_approve_permissions: false,
2354 plan_read_only: false,
2355 can_async_resume: false,
2356 bash_completion_sink: None,
2357 pre_parsed_args: None,
2358 },
2359 )
2360 .await
2361 .expect("first batch context pauses");
2362 let first_payload: serde_json::Value = serde_json::from_str(&first.result).unwrap();
2363 let first_request = &first_payload["permission_request"];
2364 assert_eq!(first_request["resource"], "docker compose up -d");
2365 assert!(!first_request["allowed_decisions"]
2366 .as_array()
2367 .unwrap()
2368 .contains(&json!("allow_once")));
2369 assert!(first_request["allowed_decisions"]
2370 .as_array()
2371 .unwrap()
2372 .contains(&json!("allow_session")));
2373 let first_matcher: crate::permission::PermissionMatcher =
2374 serde_json::from_value(first_request["suggested_matchers"][0].clone()).unwrap();
2375 config
2376 .grant_typed_scoped_session_permission(
2377 "proactive-session",
2378 crate::permission::PermissionType::ExecuteCommand,
2379 first_matcher,
2380 )
2381 .unwrap();
2382
2383 let second = executor
2384 .execute_with_context(
2385 &call,
2386 ToolExecutionContext {
2387 executing_supervisor: None,
2388 session_id: Some("proactive-session"),
2389 root_session_id: None,
2390 tool_call_id: &call.id,
2391 event_tx: Some(&event_tx),
2392 available_tool_schemas: None,
2393 bypass_permissions: false,
2394 auto_approve_permissions: false,
2395 plan_read_only: false,
2396 can_async_resume: false,
2397 bash_completion_sink: None,
2398 pre_parsed_args: None,
2399 },
2400 )
2401 .await
2402 .expect("second batch context pauses");
2403 let second_payload: serde_json::Value = serde_json::from_str(&second.result).unwrap();
2404 let second_request = &second_payload["permission_request"];
2405 assert_eq!(second_request["resource"], "registry.example.com");
2406 let second_matcher: crate::permission::PermissionMatcher =
2407 serde_json::from_value(second_request["suggested_matchers"][0].clone()).unwrap();
2408 config
2409 .grant_typed_scoped_session_permission(
2410 "proactive-session",
2411 crate::permission::PermissionType::HttpRequest,
2412 second_matcher,
2413 )
2414 .unwrap();
2415
2416 let completed = executor
2417 .execute_with_context(
2418 &call,
2419 ToolExecutionContext {
2420 executing_supervisor: None,
2421 session_id: Some("proactive-session"),
2422 root_session_id: None,
2423 tool_call_id: &call.id,
2424 event_tx: Some(&event_tx),
2425 available_tool_schemas: None,
2426 bypass_permissions: false,
2427 auto_approve_permissions: false,
2428 plan_read_only: false,
2429 can_async_resume: false,
2430 bash_completion_sink: None,
2431 pre_parsed_args: None,
2432 },
2433 )
2434 .await
2435 .expect("all authorized contexts complete the tool");
2436 assert!(completed.display_preference.is_none());
2437 let completed_payload: serde_json::Value = serde_json::from_str(&completed.result).unwrap();
2438 assert_eq!(completed_payload["status"], "permissions_authorized");
2439 assert_eq!(
2440 completed_payload["permissions"].as_array().unwrap().len(),
2441 2
2442 );
2443 }
2444
2445 #[tokio::test]
2446 async fn workspace_permission_scope_uses_only_registered_session_identity() {
2447 let registered = Arc::new(crate::permission::PermissionConfig::new());
2448 registered.register_session_workspace("registered", "/workspace/authoritative");
2449 let registered_executor = make_executor(Some(Arc::new(
2450 crate::permission::ConfigPermissionChecker::new(registered.clone()),
2451 )));
2452
2453 let first = permission_request_payload(
2454 ®istered_executor,
2455 "registered",
2456 json!({
2457 "file_path": "/tmp/first.txt",
2458 "content": "x",
2459 "cwd": "/model/chosen-a",
2460 "workspace_path": "/model/chosen-b"
2461 }),
2462 )
2463 .await;
2464 let second = permission_request_payload(
2465 ®istered_executor,
2466 "registered",
2467 json!({
2468 "file_path": "/tmp/second.txt",
2469 "content": "x",
2470 "cwd": "/model/chosen-c"
2471 }),
2472 )
2473 .await;
2474 for payload in [&first, &second] {
2475 let request = &payload["permission_request"];
2476 assert_eq!(request["workspace_path"], "/workspace/authoritative");
2477 assert!(request["allowed_decisions"]
2478 .as_array()
2479 .unwrap()
2480 .contains(&json!("allow_workspace")));
2481 }
2482
2483 registered.set_session_workspace("registered", None);
2484 let unbound = permission_request_payload(
2485 ®istered_executor,
2486 "registered",
2487 json!({
2488 "file_path": "/tmp/unbound.txt",
2489 "content": "x",
2490 "cwd": "/workspace/authoritative"
2491 }),
2492 )
2493 .await;
2494 assert!(unbound["permission_request"]["workspace_path"].is_null());
2495 assert!(!unbound["permission_request"]["allowed_decisions"]
2496 .as_array()
2497 .unwrap()
2498 .contains(&json!("allow_workspace")));
2499
2500 registered.set_session_workspace("registered", Some("/workspace/rebound".to_string()));
2501 let rebound = permission_request_payload(
2502 ®istered_executor,
2503 "registered",
2504 json!({
2505 "file_path": "/tmp/rebound.txt",
2506 "content": "x",
2507 "workspace_path": "/workspace/authoritative"
2508 }),
2509 )
2510 .await;
2511 assert_eq!(
2512 rebound["permission_request"]["workspace_path"],
2513 "/workspace/rebound"
2514 );
2515
2516 let unregistered = Arc::new(crate::permission::PermissionConfig::new());
2517 let unregistered_executor = make_executor(Some(Arc::new(
2518 crate::permission::ConfigPermissionChecker::new(unregistered),
2519 )));
2520 let payload = permission_request_payload(
2521 &unregistered_executor,
2522 "unregistered",
2523 json!({
2524 "file_path": "/tmp/unregistered.txt",
2525 "content": "x",
2526 "cwd": "/model/chosen",
2527 "workspace_path": "/also/model/chosen"
2528 }),
2529 )
2530 .await;
2531 let request = &payload["permission_request"];
2532 assert!(request["workspace_path"].is_null());
2533 assert!(!request["allowed_decisions"]
2534 .as_array()
2535 .unwrap()
2536 .contains(&json!("allow_workspace")));
2537 }
2538
2539 #[tokio::test]
2540 async fn check_permissions_for_returns_none_when_permitted() {
2541 let executor = make_executor(None);
2544 let call = make_tool_call("Read", json!({"file_path": "/tmp/whatever"}));
2545 let ctx = ToolExecutionContext::none(&call.id);
2546 let decision = executor
2547 .check_permissions_for(&call, &ctx)
2548 .await
2549 .expect("no checker means no gate");
2550 assert!(decision.is_none(), "no checker must yield Ok(None)");
2551 }
2552
2553 struct HostStub {
2556 approve: bool,
2557 }
2558
2559 #[async_trait]
2560 impl crate::approval::ApprovalProxy for HostStub {
2561 async fn request_approval(&self, _ask: crate::approval::ApprovalAsk) -> bool {
2562 self.approve
2563 }
2564 }
2565
2566 #[tokio::test]
2567 async fn approval_proxy_grant_lets_gated_tool_proceed() {
2568 let dir = tempfile::tempdir().unwrap();
2573 let path = dir.path().join("approved.txt");
2574 let path_str = path.to_str().unwrap().to_string();
2575 let config = Arc::new(crate::permission::PermissionConfig::new());
2576 config.set_ask_rules([format!("Write({}/**)", dir.path().to_str().unwrap())]);
2577 let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
2578 let executor = make_executor(Some(checker));
2579
2580 let call = make_tool_call("Write", json!({"file_path": path_str, "content": "ok"}));
2581 let ctx = ToolExecutionContext {
2582 executing_supervisor: None,
2583 session_id: Some("s-worker"),
2584 root_session_id: None,
2585 tool_call_id: &call.id,
2586 event_tx: None,
2587 available_tool_schemas: None,
2588 bypass_permissions: false,
2589 auto_approve_permissions: false,
2590 plan_read_only: false,
2591 can_async_resume: false,
2592 bash_completion_sink: None,
2593 pre_parsed_args: None,
2594 };
2595
2596 let proxy: Arc<dyn crate::approval::ApprovalProxy> = Arc::new(HostStub { approve: true });
2597 let result = crate::approval::with_approval_proxy(
2598 Some(proxy),
2599 executor.execute_with_context(&call, ctx),
2600 )
2601 .await;
2602
2603 assert!(
2604 result.is_ok(),
2605 "host grant should let the write through: {result:?}"
2606 );
2607 assert_eq!(fs::read_to_string(&path).await.unwrap(), "ok");
2608 }
2609
2610 #[tokio::test]
2611 async fn approval_proxy_deny_fails_gated_tool_closed() {
2612 let dir = tempfile::tempdir().unwrap();
2615 let path = dir.path().join("denied.txt");
2616 let path_str = path.to_str().unwrap().to_string();
2617 let config = Arc::new(crate::permission::PermissionConfig::new());
2618 config.set_ask_rules([format!("Write({}/**)", dir.path().to_str().unwrap())]);
2619 let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
2620 let executor = make_executor(Some(checker));
2621
2622 let call = make_tool_call("Write", json!({"file_path": path_str, "content": "nope"}));
2623 let ctx = ToolExecutionContext {
2624 executing_supervisor: None,
2625 session_id: Some("s-worker"),
2626 root_session_id: None,
2627 tool_call_id: &call.id,
2628 event_tx: None,
2629 available_tool_schemas: None,
2630 bypass_permissions: false,
2631 auto_approve_permissions: false,
2632 plan_read_only: false,
2633 can_async_resume: false,
2634 bash_completion_sink: None,
2635 pre_parsed_args: None,
2636 };
2637
2638 let proxy: Arc<dyn crate::approval::ApprovalProxy> = Arc::new(HostStub { approve: false });
2639 let result = crate::approval::with_approval_proxy(
2640 Some(proxy),
2641 executor.execute_with_context(&call, ctx),
2642 )
2643 .await;
2644
2645 assert!(
2646 matches!(result, Err(ToolError::Execution(ref m)) if m.contains("denied by host")),
2647 "host deny should fail the tool closed: {result:?}"
2648 );
2649 assert!(fs::metadata(&path).await.is_err());
2650 }
2651
2652 #[tokio::test]
2653 async fn tool_can_stream_events_via_execute_with_context() {
2654 struct StreamingTool;
2655
2656 #[async_trait]
2657 impl Tool for StreamingTool {
2658 fn name(&self) -> &str {
2659 "streaming_tool"
2660 }
2661
2662 fn description(&self) -> &str {
2663 "streams one token"
2664 }
2665
2666 fn parameters_schema(&self) -> serde_json::Value {
2667 json!({"type":"object","properties":{}})
2668 }
2669
2670 async fn invoke(
2671 &self,
2672 _args: serde_json::Value,
2673 ctx: ToolCtx,
2674 ) -> Result<ToolOutcome, ToolError> {
2675 ctx.emit(AgentEvent::Token {
2676 content: "stream".to_string(),
2677 })
2678 .await;
2679 Ok(ToolOutcome::Completed(ToolResult {
2680 success: true,
2681 result: "ok".to_string(),
2682 display_preference: None,
2683 images: Vec::new(),
2684 }))
2685 }
2686 }
2687
2688 let executor = BuiltinToolExecutor::new();
2689 executor
2690 .register_tool(StreamingTool)
2691 .expect("register streaming tool");
2692
2693 let (tx, mut rx) = mpsc::channel(8);
2694 let call = make_tool_call("streaming_tool", json!({}));
2695
2696 let result = executor
2697 .execute_with_context(
2698 &call,
2699 ToolExecutionContext {
2700 executing_supervisor: None,
2701 session_id: Some("s1"),
2702 root_session_id: None,
2703 tool_call_id: &call.id,
2704 event_tx: Some(&tx),
2705 available_tool_schemas: None,
2706 bypass_permissions: false,
2707 auto_approve_permissions: false,
2708 plan_read_only: false,
2709 can_async_resume: false,
2710 bash_completion_sink: None,
2711 pre_parsed_args: None,
2712 },
2713 )
2714 .await
2715 .expect("execute tool");
2716
2717 assert!(result.success);
2718 assert_eq!(result.result, "ok");
2719
2720 let ev = rx.recv().await.expect("expected streamed event");
2721 assert!(
2722 matches!(ev, AgentEvent::ToolToken { tool_call_id, content } if tool_call_id == "call_1" && content == "stream")
2723 );
2724 }
2725
2726 #[tokio::test]
2727 async fn removed_legacy_tools_return_not_found() {
2728 let executor = BuiltinToolExecutor::new();
2729
2730 for legacy in ["claude_code", "search_in_file", "search_in_project"] {
2731 let call = make_tool_call(legacy, json!({}));
2732 let result = executor.execute(&call).await;
2733 assert!(matches!(result, Err(ToolError::NotFound(_))));
2734 }
2735 }
2736
2737 #[tokio::test]
2738 async fn executor_prefers_exact_tool_name_before_builtin_alias() {
2739 struct CustomSpawnSessionTool;
2740
2741 #[async_trait]
2742 impl Tool for CustomSpawnSessionTool {
2743 fn name(&self) -> &str {
2744 "spawn_session"
2745 }
2746
2747 fn description(&self) -> &str {
2748 "custom tool for regression coverage"
2749 }
2750
2751 fn parameters_schema(&self) -> serde_json::Value {
2752 json!({"type":"object","properties":{}})
2753 }
2754
2755 async fn invoke(
2756 &self,
2757 _args: serde_json::Value,
2758 _ctx: ToolCtx,
2759 ) -> Result<ToolOutcome, ToolError> {
2760 Ok(ToolOutcome::Completed(ToolResult {
2761 success: true,
2762 result: "custom-spawn-session".to_string(),
2763 display_preference: None,
2764 images: Vec::new(),
2765 }))
2766 }
2767 }
2768
2769 let executor = BuiltinToolExecutorBuilder::new()
2770 .with_tool(CustomSpawnSessionTool)
2771 .expect("register custom spawn_session tool")
2772 .build();
2773
2774 let call = make_tool_call("spawn_session", json!({}));
2775 let result = executor.execute(&call).await.expect("execute custom tool");
2776 assert!(result.success);
2777 assert_eq!(result.result, "custom-spawn-session");
2778 }
2779
2780 struct ExactRoutingTool {
2781 name: &'static str,
2782 label: &'static str,
2783 args_sensitive: bool,
2784 }
2785
2786 #[async_trait]
2787 impl Tool for ExactRoutingTool {
2788 fn name(&self) -> &str {
2789 self.name
2790 }
2791
2792 fn description(&self) -> &str {
2793 "exact routing regression tool"
2794 }
2795
2796 fn parameters_schema(&self) -> serde_json::Value {
2797 json!({"type":"object","properties":{}})
2798 }
2799
2800 fn classify(&self, args: &serde_json::Value) -> bamboo_agent_core::ToolClass {
2801 let has_builtin_normalized_arg = ["file_path", "command", "pattern"]
2802 .iter()
2803 .any(|key| args.get(key).is_some());
2804 if self.args_sensitive && !has_builtin_normalized_arg {
2805 bamboo_agent_core::ToolClass::READONLY_PARALLEL
2806 } else {
2807 bamboo_agent_core::ToolClass::MUTATING_SERIAL
2808 }
2809 }
2810
2811 async fn invoke(
2812 &self,
2813 args: serde_json::Value,
2814 _ctx: ToolCtx,
2815 ) -> Result<ToolOutcome, ToolError> {
2816 Ok(ToolOutcome::Completed(ToolResult {
2817 success: true,
2818 result: json!({"label": self.label, "args": args}).to_string(),
2819 display_preference: None,
2820 images: Vec::new(),
2821 }))
2822 }
2823 }
2824
2825 #[tokio::test]
2826 async fn executor_preserves_namespaced_exact_identity_and_unqualified_collision() {
2827 let executor = BuiltinToolExecutorBuilder::new()
2828 .with_tool(ExactRoutingTool {
2829 name: "a::custom_tool",
2830 label: "namespaced",
2831 args_sensitive: false,
2832 })
2833 .expect("register namespaced tool")
2834 .with_tool(ExactRoutingTool {
2835 name: "custom_tool",
2836 label: "unqualified",
2837 args_sensitive: false,
2838 })
2839 .expect("register unqualified tool")
2840 .build();
2841
2842 assert!(executor.owns_exact_tool("a::custom_tool"));
2843 assert!(executor.owns_exact_tool("custom_tool"));
2844 assert!(!executor.owns_exact_tool("A::custom_tool"));
2845 let names: Vec<String> = executor
2846 .list_tools()
2847 .into_iter()
2848 .map(|schema| schema.function.name)
2849 .collect();
2850 assert!(names.contains(&"a::custom_tool".to_string()));
2851 assert!(names.contains(&"custom_tool".to_string()));
2852
2853 let namespaced = executor
2854 .execute(&make_tool_call("a::custom_tool", json!({})))
2855 .await
2856 .expect("execute namespaced exact tool");
2857 let unqualified = executor
2858 .execute(&make_tool_call("custom_tool", json!({})))
2859 .await
2860 .expect("execute unqualified exact tool");
2861 assert_eq!(
2862 serde_json::from_str::<serde_json::Value>(&namespaced.result).unwrap()["label"],
2863 "namespaced"
2864 );
2865 assert_eq!(
2866 serde_json::from_str::<serde_json::Value>(&unqualified.result).unwrap()["label"],
2867 "unqualified"
2868 );
2869 }
2870
2871 #[tokio::test]
2872 async fn exact_canonical_shadows_do_not_inherit_builtin_argument_provenance() {
2873 let executor = BuiltinToolExecutorBuilder::new()
2874 .with_tool(ExactRoutingTool {
2875 name: "Read",
2876 label: "exact-read",
2877 args_sensitive: true,
2878 })
2879 .expect("register exact Read shadow")
2880 .with_tool(ExactRoutingTool {
2881 name: "Write",
2882 label: "exact-write",
2883 args_sensitive: true,
2884 })
2885 .expect("register exact Write shadow")
2886 .with_tool(ExactRoutingTool {
2887 name: "Edit",
2888 label: "exact-edit",
2889 args_sensitive: true,
2890 })
2891 .expect("register exact Edit shadow")
2892 .with_tool(ExactRoutingTool {
2893 name: "Bash",
2894 label: "exact-bash",
2895 args_sensitive: true,
2896 })
2897 .expect("register exact Bash shadow")
2898 .with_tool(ExactRoutingTool {
2899 name: "Glob",
2900 label: "exact-glob",
2901 args_sensitive: true,
2902 })
2903 .expect("register exact Glob shadow")
2904 .with_default_tools()
2905 .build();
2906
2907 let cases = [
2908 ("Read", json!({"path": "/tmp/custom-read"}), "file_path"),
2909 ("Write", json!({"path": "/tmp/custom-write"}), "file_path"),
2910 ("Edit", json!({"path": "/tmp/custom-edit"}), "file_path"),
2911 ("Bash", json!({"cmd": "custom-command"}), "command"),
2912 (
2913 "Glob",
2914 json!({"path": "/tmp/custom-glob", "recursive": true}),
2915 "pattern",
2916 ),
2917 ];
2918
2919 for (name, args, normalized_key) in cases {
2920 let call = make_tool_call(name, args.clone());
2921 assert_eq!(
2922 executor.call_mutability(&call),
2923 crate::ToolMutability::ReadOnly,
2924 "custom {name} classification must see the original args"
2925 );
2926 assert!(
2927 executor.call_concurrency_safe(&call),
2928 "custom {name} classification must remain parallel-safe"
2929 );
2930
2931 let result = executor
2932 .execute(&call)
2933 .await
2934 .unwrap_or_else(|error| panic!("execute custom {name}: {error}"));
2935 let result: serde_json::Value = serde_json::from_str(&result.result).unwrap();
2936 assert_eq!(result["args"], args, "custom {name} args changed");
2937 assert!(result["args"].get(normalized_key).is_none());
2938 }
2939
2940 let permission_executor = BuiltinToolExecutorBuilder::new()
2945 .with_tool(ExactRoutingTool {
2946 name: "Read",
2947 label: "permission-read",
2948 args_sensitive: true,
2949 })
2950 .expect("register permission-aware Read shadow")
2951 .with_tool(ExactRoutingTool {
2952 name: "Glob",
2953 label: "permission-glob",
2954 args_sensitive: true,
2955 })
2956 .expect("register permission-aware Glob shadow")
2957 .with_default_tools()
2958 .with_permission_checker(Arc::new(crate::permission::AllowAllPermissionChecker))
2959 .build();
2960 for (name, args) in [
2961 ("Read", json!({"path": "/tmp/permission-read"})),
2962 (
2963 "Glob",
2964 json!({"path": "/tmp/permission-glob", "recursive": true}),
2965 ),
2966 ] {
2967 let call = make_tool_call(name, args.clone());
2968 let ctx = ToolExecutionContext::none(&call.id);
2969 assert!(permission_executor
2970 .check_permissions_for(&call, &ctx)
2971 .await
2972 .expect("permission check")
2973 .is_none());
2974 assert_eq!(
2975 permission_executor.call_mutability(&call),
2976 crate::ToolMutability::ReadOnly
2977 );
2978 assert!(permission_executor.call_concurrency_safe(&call));
2979 let result = permission_executor.execute(&call).await.unwrap();
2980 let result: serde_json::Value = serde_json::from_str(&result.result).unwrap();
2981 assert_eq!(result["args"], args);
2982 }
2983 }
2984
2985 #[tokio::test]
2986 async fn exact_apply_patch_keeps_original_args_and_classification() {
2987 let executor = BuiltinToolExecutorBuilder::new()
2988 .with_filesystem_tool("Edit")
2989 .expect("register builtin Edit")
2990 .with_tool(ExactRoutingTool {
2991 name: "apply_patch",
2992 label: "exact-apply-patch",
2993 args_sensitive: true,
2994 })
2995 .expect("register exact apply_patch shadow")
2996 .build();
2997 let call = make_tool_call("apply_patch", json!({"path": "/tmp/exact-shadow"}));
2998
2999 let (mutability, parallel_safe) = executor.call_parallel_classification(&call);
3000 assert_eq!(mutability, crate::ToolMutability::ReadOnly);
3001 assert!(parallel_safe);
3002
3003 let result = executor.execute(&call).await.expect("execute exact shadow");
3004 let result: serde_json::Value = serde_json::from_str(&result.result).unwrap();
3005 assert_eq!(result["label"], "exact-apply-patch");
3006 assert_eq!(result["args"]["path"], "/tmp/exact-shadow");
3007 assert!(result["args"].get("file_path").is_none());
3008 }
3009
3010 #[tokio::test]
3011 async fn exact_permission_seam_preserves_default_apply_patch_builtin_provenance() {
3012 let executor = BuiltinToolExecutorBuilder::new()
3013 .with_filesystem_tool("Edit")
3014 .expect("register builtin Edit")
3015 .with_permission_checker(Arc::new(crate::permission::AllowAllPermissionChecker))
3016 .build();
3017 let raw_args = json!({
3018 "path": "/tmp/exact-permission-apply-patch.txt",
3019 "old_string": "before",
3020 "new_string": "after"
3021 });
3022 let call = make_tool_call("default::apply_patch", raw_args.clone());
3023 let ctx = ToolExecutionContext {
3024 pre_parsed_args: Some(&raw_args),
3025 ..ToolExecutionContext::none(&call.id)
3026 };
3027
3028 assert!(executor
3029 .check_permissions_for_exact(&call, "Edit", &ctx)
3030 .await
3031 .expect("normalized builtin permission check")
3032 .is_none());
3033 assert_eq!(call.function.name, "default::apply_patch");
3034 assert_eq!(
3035 serde_json::from_str::<serde_json::Value>(&call.function.arguments).unwrap(),
3036 raw_args
3037 );
3038 }
3039
3040 #[tokio::test]
3041 async fn unshadowed_alias_and_namespace_keep_legacy_argument_compatibility() {
3042 let dir = tempfile::tempdir().unwrap();
3043 let path = dir.path().join("legacy-alias.txt");
3044 fs::write(&path, "before").await.unwrap();
3045 let executor = BuiltinToolExecutorBuilder::new()
3046 .with_filesystem_tool("Edit")
3047 .expect("register builtin Edit")
3048 .with_permission_checker(Arc::new(crate::permission::AllowAllPermissionChecker))
3049 .build();
3050
3051 let result = executor
3052 .execute(&make_tool_call(
3053 "default::apply_patch",
3054 json!({
3055 "path": path,
3056 "old_string": "before",
3057 "new_string": "after"
3058 }),
3059 ))
3060 .await
3061 .expect("execute unshadowed alias");
3062 assert!(result.success);
3063 assert_eq!(fs::read_to_string(path).await.unwrap(), "after");
3064 }
3065
3066 struct EchoArgsTool;
3071
3072 #[async_trait]
3073 impl Tool for EchoArgsTool {
3074 fn name(&self) -> &str {
3075 "echo_args"
3076 }
3077 fn description(&self) -> &str {
3078 "echoes the `v` arg"
3079 }
3080 fn parameters_schema(&self) -> serde_json::Value {
3081 json!({"type":"object","properties":{"v":{"type":"string"}}})
3082 }
3083 async fn invoke(
3084 &self,
3085 args: serde_json::Value,
3086 _ctx: ToolCtx,
3087 ) -> Result<ToolOutcome, ToolError> {
3088 let v = args
3089 .get("v")
3090 .and_then(serde_json::Value::as_str)
3091 .unwrap_or("<none>")
3092 .to_string();
3093 Ok(ToolOutcome::Completed(ToolResult {
3094 success: true,
3095 result: v,
3096 display_preference: None,
3097 images: Vec::new(),
3098 }))
3099 }
3100 }
3101
3102 fn ctx_with_pre_parsed<'a>(
3103 call_id: &'a str,
3104 pre_parsed: Option<&'a serde_json::Value>,
3105 ) -> ToolExecutionContext<'a> {
3106 ToolExecutionContext {
3107 executing_supervisor: None,
3108 session_id: Some("s-106"),
3109 root_session_id: None,
3110 tool_call_id: call_id,
3111 event_tx: None,
3112 available_tool_schemas: None,
3113 bypass_permissions: false,
3114 auto_approve_permissions: false,
3115 plan_read_only: false,
3116 can_async_resume: false,
3117 bash_completion_sink: None,
3118 pre_parsed_args: pre_parsed,
3119 }
3120 }
3121
3122 #[tokio::test]
3123 async fn execute_with_context_reuses_pre_parsed_args_without_reparsing() {
3124 let executor = BuiltinToolExecutor::new();
3130 executor.register_tool(EchoArgsTool).expect("register echo");
3131
3132 let call = make_tool_call("echo_args", json!({"v": "raw"}));
3133 let pre_parsed = json!({"v": "preparsed"});
3134 let ctx = ctx_with_pre_parsed(&call.id, Some(&pre_parsed));
3135
3136 let result = executor
3137 .execute_with_context(&call, ctx)
3138 .await
3139 .expect("execute echo tool");
3140 assert_eq!(
3141 result.result, "preparsed",
3142 "executor must reuse pre_parsed_args, not re-parse the raw string"
3143 );
3144 }
3145
3146 #[tokio::test]
3147 async fn execute_with_context_parses_raw_when_no_pre_parsed_args() {
3148 let executor = BuiltinToolExecutor::new();
3152 executor.register_tool(EchoArgsTool).expect("register echo");
3153
3154 let call = make_tool_call("echo_args", json!({"v": "raw"}));
3155 let ctx = ctx_with_pre_parsed(&call.id, None);
3156
3157 let result = executor
3158 .execute_with_context(&call, ctx)
3159 .await
3160 .expect("execute echo tool");
3161 assert_eq!(
3162 result.result, "raw",
3163 "without pre_parsed_args the executor parses the raw string as before"
3164 );
3165 }
3166
3167 #[tokio::test]
3168 async fn execute_with_context_malformed_args_repair_unchanged_without_pre_parsed() {
3169 let dir = tempfile::tempdir().unwrap();
3173 let path = dir.path().join("recovered-no-preparsed.txt");
3174 let malformed_args = format!(
3175 r#"{{"file_path":"{}","content":"recovered content""#,
3176 path.display()
3177 );
3178
3179 let executor = BuiltinToolExecutor::new();
3180 let call = make_tool_call_with_raw_args("Write", &malformed_args);
3181 let ctx = ctx_with_pre_parsed(&call.id, None);
3182
3183 let result = executor
3184 .execute_with_context(&call, ctx)
3185 .await
3186 .expect("truncated JSON should be auto-repaired");
3187 assert!(result.success);
3188 let written = fs::read_to_string(&path).await.expect("file written");
3189 assert_eq!(written, "recovered content");
3190 }
3191
3192 #[tokio::test]
3193 async fn successful_write_emits_one_bounded_file_changed_event() {
3194 let dir = tempfile::tempdir().unwrap();
3195 let path = dir.path().join("write-event.txt");
3196 let path_string = path.to_string_lossy().into_owned();
3197 let padded_path = format!(" {path_string} ");
3198 let recorder = Arc::new(InMemoryToolEventRecorder::new(4).unwrap());
3199 let executor = BuiltinToolExecutorBuilder::new()
3200 .with_filesystem_tool("Write")
3201 .unwrap()
3202 .with_tool_event_publisher(recorder.clone())
3203 .build();
3204 let call = make_tool_call_with_id(
3205 "write-call",
3206 "Write",
3207 json!({"file_path": padded_path, "content": "written"}),
3208 );
3209
3210 let result = executor
3211 .execute_with_context(
3212 &call,
3213 tool_event_context(&call, Some("write-session"), Some("write-root-session")),
3214 )
3215 .await
3216 .unwrap();
3217
3218 assert!(result.success);
3219 assert_eq!(fs::read_to_string(path).await.unwrap(), "written");
3220 assert_single_file_changed(
3221 &recorder,
3222 "write-session",
3223 "write-root-session",
3224 "Write",
3225 "write-call",
3226 &path_string,
3227 );
3228 }
3229
3230 #[tokio::test]
3231 async fn successful_edit_emits_one_bounded_file_changed_event() {
3232 let dir = tempfile::tempdir().unwrap();
3233 let path = dir.path().join("edit-event.txt");
3234 fs::write(&path, "before\n").await.unwrap();
3235 let path_string = path.to_string_lossy().into_owned();
3236 let padded_path = format!(" {path_string} ");
3237 let recorder = Arc::new(InMemoryToolEventRecorder::new(4).unwrap());
3238 let executor = BuiltinToolExecutor::new().with_tool_event_publisher(recorder.clone());
3239 let read =
3240 make_tool_call_with_id("edit-read-call", "Read", json!({"file_path": padded_path}));
3241 executor
3242 .execute_with_context(
3243 &read,
3244 tool_event_context(&read, Some("edit-session"), Some("edit-root-session")),
3245 )
3246 .await
3247 .unwrap();
3248 assert!(recorder.try_snapshot().unwrap().is_empty());
3249
3250 let edit = make_tool_call_with_id(
3251 "edit-call",
3252 "Edit",
3253 json!({
3254 "file_path": format!(" {path_string} "),
3255 "old_string": "before",
3256 "new_string": "after"
3257 }),
3258 );
3259 let result = executor
3260 .execute_with_context(
3261 &edit,
3262 tool_event_context(&edit, Some("edit-session"), Some("edit-root-session")),
3263 )
3264 .await
3265 .unwrap();
3266
3267 assert!(result.success);
3268 assert_eq!(fs::read_to_string(path).await.unwrap(), "after\n");
3269 assert_single_file_changed(
3270 &recorder,
3271 "edit-session",
3272 "edit-root-session",
3273 "Edit",
3274 "edit-call",
3275 &path_string,
3276 );
3277 }
3278
3279 #[tokio::test]
3280 async fn successful_apply_patch_alias_emits_canonical_edit_with_original_call_id() {
3281 let dir = tempfile::tempdir().unwrap();
3282 let path = dir.path().join("apply-patch-event.txt");
3283 fs::write(&path, "alpha\nbeta\n").await.unwrap();
3284 let path_string = path.to_string_lossy().into_owned();
3285 let recorder = Arc::new(InMemoryToolEventRecorder::new(4).unwrap());
3286 let executor = BuiltinToolExecutor::new().with_tool_event_publisher(recorder.clone());
3287 let read = make_tool_call_with_id(
3288 "apply-patch-read-call",
3289 "Read",
3290 json!({"file_path": path_string}),
3291 );
3292 executor
3293 .execute_with_context(
3294 &read,
3295 tool_event_context(&read, Some("alias-session"), Some("alias-root-session")),
3296 )
3297 .await
3298 .unwrap();
3299
3300 let edit = make_tool_call_with_id(
3301 "model-original-alias-call",
3302 "apply_patch",
3303 json!({
3304 "path": format!(" {path_string} "),
3305 "old_string": "beta",
3306 "new_string": "BETA"
3307 }),
3308 );
3309 let result = executor
3310 .execute_with_context(
3311 &edit,
3312 tool_event_context(&edit, Some("alias-session"), Some("alias-root-session")),
3313 )
3314 .await
3315 .unwrap();
3316
3317 assert!(result.success);
3318 assert_eq!(fs::read_to_string(path).await.unwrap(), "alpha\nBETA\n");
3319 assert_single_file_changed(
3320 &recorder,
3321 "alias-session",
3322 "alias-root-session",
3323 "Edit",
3324 "model-original-alias-call",
3325 &path_string,
3326 );
3327 }
3328
3329 #[tokio::test]
3330 async fn successful_notebook_edit_emits_one_bounded_file_changed_event() {
3331 let dir = tempfile::tempdir().unwrap();
3332 let path = dir.path().join("notebook-event.ipynb");
3333 fs::write(
3334 &path,
3335 r#"{"cells":[],"metadata":{},"nbformat":4,"nbformat_minor":5}"#,
3336 )
3337 .await
3338 .unwrap();
3339 let path_string = path.to_string_lossy().into_owned();
3340 let recorder = Arc::new(InMemoryToolEventRecorder::new(4).unwrap());
3341 let executor = BuiltinToolExecutorBuilder::new()
3342 .with_filesystem_tool("NotebookEdit")
3343 .unwrap()
3344 .with_tool_event_publisher(recorder.clone())
3345 .build();
3346 let call = make_tool_call_with_id(
3347 "notebook-call",
3348 "NotebookEdit",
3349 json!({
3350 "notebook_path": format!(" {path_string} "),
3351 "new_source": "print('hello')",
3352 "cell_type": "code",
3353 "edit_mode": "insert"
3354 }),
3355 );
3356
3357 let result = executor
3358 .execute_with_context(
3359 &call,
3360 tool_event_context(
3361 &call,
3362 Some("notebook-session"),
3363 Some("notebook-root-session"),
3364 ),
3365 )
3366 .await
3367 .unwrap();
3368
3369 assert!(result.success);
3370 assert_single_file_changed(
3371 &recorder,
3372 "notebook-session",
3373 "notebook-root-session",
3374 "NotebookEdit",
3375 "notebook-call",
3376 &path_string,
3377 );
3378 }
3379
3380 #[cfg(unix)]
3381 #[tokio::test]
3382 async fn write_through_intermediate_symlink_fails_and_emits_zero_events() {
3383 use std::os::unix::fs::symlink;
3384
3385 let workspace = tempfile::tempdir().unwrap();
3386 let external = tempfile::tempdir().unwrap();
3387 let linked_dir = workspace.path().join("linked");
3388 symlink(external.path(), &linked_dir).unwrap();
3389 let target = linked_dir.join("write.txt");
3390 let recorder = Arc::new(InMemoryToolEventRecorder::new(4).unwrap());
3391 let executor = BuiltinToolExecutorBuilder::new()
3392 .with_filesystem_tool("Write")
3393 .unwrap()
3394 .with_tool_event_publisher(recorder.clone())
3395 .build();
3396 let call = make_tool_call_with_id(
3397 "symlink-write",
3398 "Write",
3399 json!({"file_path": target, "content": "must-not-write"}),
3400 );
3401
3402 let result = executor
3403 .execute_with_context(
3404 &call,
3405 tool_event_context(&call, Some("symlink-session"), Some("symlink-root")),
3406 )
3407 .await;
3408 assert!(
3409 result.is_err() || result.as_ref().is_ok_and(|result| !result.success),
3410 "Write must fail closed through an intermediate symlink"
3411 );
3412 assert!(!external.path().join("write.txt").exists());
3413 assert!(recorder.try_snapshot().unwrap().is_empty());
3414 }
3415
3416 #[cfg(unix)]
3417 #[tokio::test]
3418 async fn edit_of_symlinked_file_fails_and_emits_zero_events() {
3419 use std::os::unix::fs::symlink;
3420
3421 let dir = tempfile::tempdir().unwrap();
3422 let real = dir.path().join("real.txt");
3423 let linked = dir.path().join("linked.txt");
3424 fs::write(&real, "before\n").await.unwrap();
3425 symlink(&real, &linked).unwrap();
3426 let recorder = Arc::new(InMemoryToolEventRecorder::new(4).unwrap());
3427 let executor = BuiltinToolExecutor::new().with_tool_event_publisher(recorder.clone());
3428 let read =
3429 make_tool_call_with_id("symlink-edit-read", "Read", json!({"file_path": linked}));
3430 let _ = executor
3431 .execute_with_context(
3432 &read,
3433 tool_event_context(&read, Some("symlink-session"), Some("symlink-root")),
3434 )
3435 .await;
3436 let edit = make_tool_call_with_id(
3437 "symlink-edit",
3438 "Edit",
3439 json!({
3440 "file_path": linked,
3441 "old_string": "before",
3442 "new_string": "after"
3443 }),
3444 );
3445
3446 let result = executor
3447 .execute_with_context(
3448 &edit,
3449 tool_event_context(&edit, Some("symlink-session"), Some("symlink-root")),
3450 )
3451 .await;
3452 assert!(
3453 result.is_err() || result.as_ref().is_ok_and(|result| !result.success),
3454 "Edit must fail closed for a symlinked final file"
3455 );
3456 assert_eq!(fs::read_to_string(&real).await.unwrap(), "before\n");
3457 assert!(recorder.try_snapshot().unwrap().is_empty());
3458 }
3459
3460 #[cfg(unix)]
3461 #[tokio::test]
3462 async fn notebook_edit_through_intermediate_symlink_fails_and_emits_zero_events() {
3463 use std::os::unix::fs::symlink;
3464
3465 let workspace = tempfile::tempdir().unwrap();
3466 let external = tempfile::tempdir().unwrap();
3467 let real_notebook = external.path().join("real.ipynb");
3468 let original = r#"{"cells":[],"metadata":{},"nbformat":4,"nbformat_minor":5}"#;
3469 fs::write(&real_notebook, original).await.unwrap();
3470 let linked_dir = workspace.path().join("linked");
3471 symlink(external.path(), &linked_dir).unwrap();
3472 let recorder = Arc::new(InMemoryToolEventRecorder::new(4).unwrap());
3473 let executor = BuiltinToolExecutorBuilder::new()
3474 .with_filesystem_tool("NotebookEdit")
3475 .unwrap()
3476 .with_tool_event_publisher(recorder.clone())
3477 .build();
3478 let call = make_tool_call_with_id(
3479 "symlink-notebook",
3480 "NotebookEdit",
3481 json!({
3482 "notebook_path": linked_dir.join("real.ipynb"),
3483 "new_source": "print('must not write')",
3484 "cell_type": "code",
3485 "edit_mode": "insert"
3486 }),
3487 );
3488
3489 let result = executor
3490 .execute_with_context(
3491 &call,
3492 tool_event_context(&call, Some("symlink-session"), Some("symlink-root")),
3493 )
3494 .await;
3495 assert!(
3496 result.is_err() || result.as_ref().is_ok_and(|result| !result.success),
3497 "NotebookEdit must fail closed through an intermediate symlink"
3498 );
3499 assert_eq!(fs::read_to_string(&real_notebook).await.unwrap(), original);
3500 assert!(recorder.try_snapshot().unwrap().is_empty());
3501 }
3502
3503 #[tokio::test]
3504 async fn failed_and_non_successful_mutations_emit_no_event() {
3505 let recorder = Arc::new(InMemoryToolEventRecorder::new(4).unwrap());
3506 let executor = BuiltinToolExecutorBuilder::new()
3507 .with_filesystem_tool("Write")
3508 .unwrap()
3509 .with_tool_event_publisher(recorder.clone())
3510 .build();
3511 let failed = make_tool_call_with_id(
3512 "failed-write-call",
3513 "Write",
3514 json!({"file_path": "relative.txt", "content": "never"}),
3515 );
3516 assert!(executor
3517 .execute_with_context(
3518 &failed,
3519 tool_event_context(
3520 &failed,
3521 Some("failure-session"),
3522 Some("failure-root-session"),
3523 ),
3524 )
3525 .await
3526 .is_err());
3527 assert!(recorder.try_snapshot().unwrap().is_empty());
3528
3529 let completed_false = marked_stub_write_executor(false, recorder.clone());
3530 let call = make_tool_call_with_id(
3531 "completed-false-call",
3532 "Write",
3533 json!({"file_path": "/valid/event/path.txt"}),
3534 );
3535 let result = completed_false
3536 .execute_with_context(
3537 &call,
3538 tool_event_context(&call, Some("failure-session"), Some("failure-root-session")),
3539 )
3540 .await
3541 .unwrap();
3542 assert!(!result.success);
3543 assert!(recorder.try_snapshot().unwrap().is_empty());
3544 }
3545
3546 #[tokio::test]
3547 async fn committed_postverify_failure_emits_no_tool_event() {
3548 let dir = tempfile::tempdir().unwrap();
3549 let path = dir.path().join("postverify-conflict.txt");
3550 fs::write(&path, "before").await.unwrap();
3551 let path_string = path.to_string_lossy().into_owned();
3552 let session_id = format!("event-conflict-{}", uuid::Uuid::new_v4());
3553 let recorder = Arc::new(InMemoryToolEventRecorder::new(4).unwrap());
3554 let executor =
3555 Arc::new(BuiltinToolExecutor::new().with_tool_event_publisher(recorder.clone()));
3556
3557 let initial_read = make_tool_call_with_id(
3558 "conflict-initial-read",
3559 "Read",
3560 json!({"file_path": path_string}),
3561 );
3562 executor
3563 .execute_with_context(
3564 &initial_read,
3565 tool_event_context(
3566 &initial_read,
3567 Some(&session_id),
3568 Some("conflict-root-session"),
3569 ),
3570 )
3571 .await
3572 .unwrap();
3573 let (advance_reached, resume_advance) =
3574 crate::tools::read_tracker::pause_next_advance_for_test(&session_id, &path_string)
3575 .await;
3576
3577 let writer_executor = executor.clone();
3578 let writer_session = session_id.clone();
3579 let writer_path = path_string.clone();
3580 let writer = tokio::spawn(async move {
3581 let call = make_tool_call_with_id(
3582 "conflict-write-call",
3583 "Write",
3584 json!({"file_path": writer_path, "content": "intended"}),
3585 );
3586 writer_executor
3587 .execute_with_context(
3588 &call,
3589 tool_event_context(&call, Some(&writer_session), Some("conflict-root-session")),
3590 )
3591 .await
3592 });
3593
3594 tokio::time::timeout(
3595 std::time::Duration::from_secs(5),
3596 advance_reached.notified(),
3597 )
3598 .await
3599 .expect("Write did not reach post-write baseline advancement");
3600 fs::write(&path, "other").await.unwrap();
3601 let concurrent_read = make_tool_call_with_id(
3602 "conflict-concurrent-read",
3603 "Read",
3604 json!({"file_path": path_string}),
3605 );
3606 executor
3607 .execute_with_context(
3608 &concurrent_read,
3609 tool_event_context(
3610 &concurrent_read,
3611 Some(&session_id),
3612 Some("conflict-root-session"),
3613 ),
3614 )
3615 .await
3616 .unwrap();
3617 fs::write(&path, "intended").await.unwrap();
3618 resume_advance.notify_one();
3619
3620 let outcome = tokio::time::timeout(std::time::Duration::from_secs(5), writer)
3621 .await
3622 .expect("Write did not resume")
3623 .unwrap();
3624 assert!(
3625 matches!(outcome, Err(ToolError::Execution(ref message)) if message.contains("Write committed")),
3626 "committed postverify conflict must stay an error: {outcome:?}"
3627 );
3628 assert_eq!(fs::read_to_string(path).await.unwrap(), "intended");
3629 assert!(
3630 recorder.try_snapshot().unwrap().is_empty(),
3631 "an on-disk mutation is not a successful tool outcome"
3632 );
3633 }
3634
3635 #[tokio::test]
3636 async fn permission_pause_does_not_publish_a_success_event() {
3637 let dir = tempfile::tempdir().unwrap();
3638 let path = dir.path().join("approval-gated.txt");
3639 let config = Arc::new(crate::permission::PermissionConfig::new());
3640 config.set_ask_rules([format!("Write({}/**)", dir.path().display())]);
3641 config.register_session_workspace(
3642 "approval-session",
3643 dir.path().to_string_lossy().into_owned(),
3644 );
3645 let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
3646 let recorder = Arc::new(InMemoryToolEventRecorder::new(4).unwrap());
3647 let executor = BuiltinToolExecutorBuilder::new()
3648 .with_filesystem_tool("Write")
3649 .unwrap()
3650 .with_permission_checker(checker)
3651 .with_tool_event_publisher(recorder.clone())
3652 .build();
3653 let call = make_tool_call_with_id(
3654 "approval-call",
3655 "Write",
3656 json!({"file_path": path, "content": "not-yet"}),
3657 );
3658 let (event_tx, _event_rx) = mpsc::channel(4);
3659 let mut ctx = tool_event_context(
3660 &call,
3661 Some("approval-session"),
3662 Some("approval-root-session"),
3663 );
3664 ctx.event_tx = Some(&event_tx);
3665
3666 let result = executor.execute_with_context(&call, ctx).await.unwrap();
3667 assert!(
3668 result.success,
3669 "approval pause is a synthetic success result"
3670 );
3671 assert_eq!(
3672 result.display_preference.as_deref(),
3673 Some("request_permissions")
3674 );
3675 assert!(!path.exists(), "permission pause must not invoke Write");
3676 assert!(recorder.try_snapshot().unwrap().is_empty());
3677 }
3678
3679 #[tokio::test]
3680 async fn missing_authority_or_oversize_path_fails_closed_without_event() {
3681 let recorder = Arc::new(InMemoryToolEventRecorder::new(4).unwrap());
3682 let executor = marked_stub_write_executor(true, recorder.clone());
3683
3684 let missing_session = make_tool_call_with_id(
3685 "missing-session-call",
3686 "Write",
3687 json!({"file_path": "/bounded/path.txt"}),
3688 );
3689 assert!(
3690 executor
3691 .execute_with_context(
3692 &missing_session,
3693 tool_event_context(&missing_session, None, Some("authority-root-session"),),
3694 )
3695 .await
3696 .unwrap()
3697 .success
3698 );
3699
3700 let missing_root = make_tool_call_with_id(
3701 "missing-root-call",
3702 "Write",
3703 json!({"file_path": "/bounded/path.txt"}),
3704 );
3705 assert!(
3706 executor
3707 .execute_with_context(
3708 &missing_root,
3709 tool_event_context(&missing_root, Some("authority-session"), None),
3710 )
3711 .await
3712 .unwrap()
3713 .success
3714 );
3715
3716 let oversize_path = make_tool_call_with_id(
3717 "oversize-path-call",
3718 "Write",
3719 json!({"file_path": "x".repeat(MAX_TOOL_EVENT_PATH_BYTES + 1)}),
3720 );
3721 assert!(
3722 executor
3723 .execute_with_context(
3724 &oversize_path,
3725 tool_event_context(
3726 &oversize_path,
3727 Some("authority-session"),
3728 Some("authority-root-session"),
3729 ),
3730 )
3731 .await
3732 .unwrap()
3733 .success
3734 );
3735
3736 assert!(recorder.try_snapshot().unwrap().is_empty());
3737 }
3738
3739 #[tokio::test]
3740 async fn custom_write_name_never_acquires_builtin_event_provenance() {
3741 let recorder = Arc::new(InMemoryToolEventRecorder::new(4).unwrap());
3742 let registry = ToolRegistry::new();
3743 registry.register(StubWriteTool { success: true }).unwrap();
3744 let from_registry = BuiltinToolExecutor::with_registry(registry)
3745 .with_tool_event_publisher(recorder.clone());
3746 let first = make_tool_call_with_id(
3747 "spoof-registry-call",
3748 "Write",
3749 json!({"file_path": "/spoof/path.txt"}),
3750 );
3751 assert!(
3752 from_registry
3753 .execute_with_context(
3754 &first,
3755 tool_event_context(&first, Some("spoof-session"), Some("spoof-root-session"),),
3756 )
3757 .await
3758 .unwrap()
3759 .success
3760 );
3761
3762 let custom_before_defaults = BuiltinToolExecutorBuilder::new()
3763 .with_tool(StubWriteTool { success: true })
3764 .unwrap()
3765 .with_default_tools()
3766 .with_tool_event_publisher(recorder.clone())
3767 .build();
3768 let second = make_tool_call_with_id(
3769 "spoof-builder-order-call",
3770 "Write",
3771 json!({"file_path": "/spoof/path.txt"}),
3772 );
3773 assert!(
3774 custom_before_defaults
3775 .execute_with_context(
3776 &second,
3777 tool_event_context(&second, Some("spoof-session"), Some("spoof-root-session"),),
3778 )
3779 .await
3780 .unwrap()
3781 .success
3782 );
3783
3784 let replaced_builtin =
3785 BuiltinToolExecutor::new().with_tool_event_publisher(recorder.clone());
3786 assert!(replaced_builtin.registry().unregister("Write"));
3787 replaced_builtin
3788 .register_tool(StubWriteTool { success: true })
3789 .unwrap();
3790 let third = make_tool_call_with_id(
3791 "spoof-replaced-builtin-call",
3792 "Write",
3793 json!({"file_path": "/spoof/path.txt"}),
3794 );
3795 assert!(
3796 replaced_builtin
3797 .execute_with_context(
3798 &third,
3799 tool_event_context(&third, Some("spoof-session"), Some("spoof-root-session"),),
3800 )
3801 .await
3802 .unwrap()
3803 .success
3804 );
3805
3806 assert!(recorder.try_snapshot().unwrap().is_empty());
3807 }
3808
3809 #[tokio::test]
3810 async fn publisher_rejection_or_panic_never_changes_successful_tool_result() {
3811 let full = Arc::new(InMemoryToolEventRecorder::new(1).unwrap());
3812 full.try_publish(seed_event("seed-full")).unwrap();
3813 assert_real_write_succeeds_with_publisher(full.clone(), "full").await;
3814 let retained = full.try_snapshot().unwrap();
3815 assert_eq!(retained.len(), 1);
3816 assert_eq!(retained[0].context.tool_call_id, "seed-full");
3817
3818 let publishers: Vec<(&str, Arc<dyn ToolEventPublisher>)> = vec![
3819 (
3820 "busy",
3821 Arc::new(ReturningPublisher(ToolEventPublishError::Busy)),
3822 ),
3823 (
3824 "poisoned",
3825 Arc::new(ReturningPublisher(ToolEventPublishError::Poisoned)),
3826 ),
3827 (
3828 "failed",
3829 Arc::new(ReturningPublisher(ToolEventPublishError::Failed(
3830 "sink unavailable".to_string(),
3831 ))),
3832 ),
3833 ("enabled-panic", Arc::new(IsEnabledPanicPublisher)),
3834 ("publish-panic", Arc::new(TryPublishPanicPublisher)),
3835 ];
3836 for (label, publisher) in publishers {
3837 assert_real_write_succeeds_with_publisher(publisher, label).await;
3838 }
3839 }
3840}