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 read_only_child_checker_denies_every_side_effect_under_auto_and_bypass() {
2051 let config = Arc::new(crate::permission::PermissionConfig::new());
2052 config.set_mode(crate::permission::PermissionMode::Auto);
2053 let base: Arc<dyn crate::permission::PermissionChecker> = Arc::new(
2054 crate::permission::ConfigPermissionChecker::new(config.clone()),
2055 );
2056 let checker = Arc::new(crate::permission::ReadOnlyCommandChecker::new(base));
2057 let executor = BuiltinToolExecutorBuilder::new()
2058 .with_tool(BashTool::new())
2059 .expect("register Bash tool")
2060 .with_tool(WriteTool::new())
2061 .expect("register Write tool")
2062 .with_permission_checker(checker)
2063 .build();
2064
2065 for (mode, bypass_permissions, auto_approve_permissions) in
2070 [("auto", false, true), ("bypass", true, false)]
2071 {
2072 for command in ["pwd", "cat Cargo.toml"] {
2073 let call = make_tool_call("Bash", json!({"command": command}));
2074 let session_id = format!("planner-no-shell-{mode}");
2075 let ctx = ToolExecutionContext {
2076 executing_supervisor: None,
2077 session_id: Some(&session_id),
2078 root_session_id: None,
2079 tool_call_id: &call.id,
2080 event_tx: None,
2081 available_tool_schemas: None,
2082 bypass_permissions,
2083 auto_approve_permissions,
2084 plan_read_only: false,
2085 can_async_resume: false,
2086 bash_completion_sink: None,
2087 pre_parsed_args: None,
2088 };
2089 let error = executor
2090 .execute_with_context(&call, ctx)
2091 .await
2092 .expect_err("read-only children must not enter an ambient shell");
2093 assert!(error
2094 .to_string()
2095 .contains("Execute shell commands is disabled"));
2096 }
2097 }
2098
2099 let dir = tempfile::tempdir().unwrap();
2100 let direct_write_path = dir.path().join("planner-direct-write.txt");
2101 for (mode, bypass_permissions, auto_approve_permissions) in
2102 [("auto", false, true), ("bypass", true, false)]
2103 {
2104 let call = make_tool_call(
2105 "Write",
2106 json!({"file_path": direct_write_path, "content": "blocked"}),
2107 );
2108 let session_id = format!("planner-direct-write-{mode}");
2109 let ctx = ToolExecutionContext {
2110 executing_supervisor: None,
2111 session_id: Some(&session_id),
2112 root_session_id: None,
2113 tool_call_id: &call.id,
2114 event_tx: None,
2115 available_tool_schemas: None,
2116 bypass_permissions,
2117 auto_approve_permissions,
2118 plan_read_only: false,
2119 can_async_resume: false,
2120 bash_completion_sink: None,
2121 pre_parsed_args: None,
2122 };
2123 let error = executor
2124 .execute_with_context(&call, ctx)
2125 .await
2126 .expect_err("unadvertised direct writes must remain hard-denied");
2127 assert!(error
2128 .to_string()
2129 .contains("Write files to disk is disabled"));
2130 assert!(!direct_write_path.exists());
2131 }
2132
2133 let path = dir.path().join("planner-mutation.txt");
2134 let command = format!("printf blocked > {}", path.display());
2135 for (session_id, bypass_permissions, auto_approve_permissions) in [
2136 ("planner-auto", false, true),
2137 ("planner-bypass", true, false),
2138 ] {
2139 let call = make_tool_call("Bash", json!({"command": command.clone()}));
2140 let ctx = ToolExecutionContext {
2141 executing_supervisor: None,
2142 session_id: Some(session_id),
2143 root_session_id: None,
2144 tool_call_id: &call.id,
2145 event_tx: None,
2146 available_tool_schemas: None,
2147 bypass_permissions,
2148 auto_approve_permissions,
2149 plan_read_only: false,
2150 can_async_resume: false,
2151 bash_completion_sink: None,
2152 pre_parsed_args: None,
2153 };
2154
2155 let error = executor
2156 .execute_with_context(&call, ctx)
2157 .await
2158 .expect_err("Auto/Bypass must retain read-only child authority");
2159
2160 assert!(error.to_string().contains("Read-only child"));
2161 assert!(!path.exists());
2162 }
2163
2164 let delete_target = dir.path().join("planner-delete-target");
2165 fs::create_dir_all(&delete_target).await.unwrap();
2166 fs::write(delete_target.join("keep.txt"), "keep")
2167 .await
2168 .unwrap();
2169 let delete_command = format!("rm -rf {}", delete_target.display());
2170 for (session_id, bypass_permissions, auto_approve_permissions) in [
2171 ("planner-delete-auto", false, true),
2172 ("planner-delete-bypass", true, false),
2173 ] {
2174 let call = make_tool_call("Bash", json!({"command": delete_command.clone()}));
2175 let ctx = ToolExecutionContext {
2176 executing_supervisor: None,
2177 session_id: Some(session_id),
2178 root_session_id: None,
2179 tool_call_id: &call.id,
2180 event_tx: None,
2181 available_tool_schemas: None,
2182 bypass_permissions,
2183 auto_approve_permissions,
2184 plan_read_only: false,
2185 can_async_resume: false,
2186 bash_completion_sink: None,
2187 pre_parsed_args: None,
2188 };
2189 let error = executor
2190 .execute_with_context(&call, ctx)
2191 .await
2192 .expect_err("delete operations must remain hard-denied");
2193 assert!(error
2194 .to_string()
2195 .contains("Delete files or directories is disabled"));
2196 assert!(delete_target.exists());
2197 }
2198
2199 let git_output = dir.path().join("planner-git-output.txt");
2200 let git_command = format!("git diff --output={}", git_output.display());
2201 for (session_id, bypass_permissions, auto_approve_permissions) in [
2202 ("planner-git-auto", false, true),
2203 ("planner-git-bypass", true, false),
2204 ] {
2205 let call = make_tool_call("Bash", json!({"command": git_command.clone()}));
2206 let ctx = ToolExecutionContext {
2207 executing_supervisor: None,
2208 session_id: Some(session_id),
2209 root_session_id: None,
2210 tool_call_id: &call.id,
2211 event_tx: None,
2212 available_tool_schemas: None,
2213 bypass_permissions,
2214 auto_approve_permissions,
2215 plan_read_only: false,
2216 can_async_resume: false,
2217 bash_completion_sink: None,
2218 pre_parsed_args: None,
2219 };
2220
2221 let error = executor
2222 .execute_with_context(&call, ctx)
2223 .await
2224 .expect_err("git output flags must not bypass read-only child authority");
2225
2226 assert!(error.to_string().contains("Read-only child"));
2227 assert!(!git_output.exists());
2228 }
2229
2230 let find_output = dir.path().join("planner-find-output.txt");
2231 let denied_commands = [
2232 ("cargo", "cargo test --help".to_string(), None),
2233 (
2234 "git-signature-flag",
2235 "git log --no-ext-diff --no-textconv --show-signature -1".to_string(),
2236 None,
2237 ),
2238 (
2239 "git-signature-format",
2240 "git log --no-ext-diff --no-textconv --no-show-signature --format=%G? -1"
2241 .to_string(),
2242 None,
2243 ),
2244 (
2245 "find",
2246 format!(
2247 "find {} -fprint0 {}",
2248 dir.path().display(),
2249 find_output.display()
2250 ),
2251 Some(find_output.as_path()),
2252 ),
2253 ];
2254 for (command_kind, command, output_path) in denied_commands {
2255 for (mode, bypass_permissions, auto_approve_permissions) in
2256 [("auto", false, true), ("bypass", true, false)]
2257 {
2258 let call = make_tool_call("Bash", json!({"command": command.clone()}));
2259 let session_id = format!("planner-{command_kind}-{mode}");
2260 let ctx = ToolExecutionContext {
2261 executing_supervisor: None,
2262 session_id: Some(&session_id),
2263 root_session_id: None,
2264 tool_call_id: &call.id,
2265 event_tx: None,
2266 available_tool_schemas: None,
2267 bypass_permissions,
2268 auto_approve_permissions,
2269 plan_read_only: false,
2270 can_async_resume: false,
2271 bash_completion_sink: None,
2272 pre_parsed_args: None,
2273 };
2274
2275 let error = executor
2276 .execute_with_context(&call, ctx)
2277 .await
2278 .expect_err("executable/write-capable commands must remain denied");
2279
2280 assert!(error.to_string().contains("Read-only child"));
2281 if let Some(path) = output_path {
2282 assert!(!path.exists());
2283 }
2284 }
2285 }
2286
2287 let ansi_find_target = dir.path().join("planner-ansi-find-target");
2291 fs::create_dir_all(&ansi_find_target).await.unwrap();
2292 fs::write(ansi_find_target.join("keep.txt"), "keep")
2293 .await
2294 .unwrap();
2295 let ansi_find_command = format!(r"find {} $'-de'lete", ansi_find_target.display());
2296 for (mode, bypass_permissions, auto_approve_permissions) in
2297 [("auto", false, true), ("bypass", true, false)]
2298 {
2299 let call = make_tool_call("Bash", json!({"command": ansi_find_command.clone()}));
2300 let session_id = format!("planner-find-ansi-{mode}");
2301 let ctx = ToolExecutionContext {
2302 executing_supervisor: None,
2303 session_id: Some(&session_id),
2304 root_session_id: None,
2305 tool_call_id: &call.id,
2306 event_tx: None,
2307 available_tool_schemas: None,
2308 bypass_permissions,
2309 auto_approve_permissions,
2310 plan_read_only: false,
2311 can_async_resume: false,
2312 bash_completion_sink: None,
2313 pre_parsed_args: None,
2314 };
2315
2316 let error = executor
2317 .execute_with_context(&call, ctx)
2318 .await
2319 .expect_err("ANSI-C expansion must remain denied before Bash execution");
2320
2321 assert!(error.to_string().contains("Read-only child"));
2322 assert!(
2323 ansi_find_target.exists(),
2324 "the rejected command must not delete its target"
2325 );
2326 }
2327 }
2328
2329 #[tokio::test]
2330 async fn test_explicit_deny_overrides_bypass() {
2331 let dir = tempfile::tempdir().unwrap();
2332 let path = dir.path().join("explicit-deny.txt");
2333 let path_str = path.to_str().unwrap();
2334 let config = Arc::new(crate::permission::PermissionConfig::new());
2335 config.add_rule(crate::permission::PermissionRule::new(
2336 crate::permission::PermissionType::WriteFile,
2337 path_str,
2338 false,
2339 ));
2340 let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
2341 let executor = make_executor(Some(checker));
2342 let call = make_tool_call(
2343 "Write",
2344 json!({"file_path": path_str, "content": "blocked"}),
2345 );
2346 let ctx = ToolExecutionContext {
2347 executing_supervisor: None,
2348 session_id: Some("s-explicit-deny"),
2349 root_session_id: None,
2350 tool_call_id: &call.id,
2351 event_tx: None,
2352 available_tool_schemas: None,
2353 bypass_permissions: true,
2354 auto_approve_permissions: false,
2355 plan_read_only: false,
2356 can_async_resume: false,
2357 bash_completion_sink: None,
2358 pre_parsed_args: None,
2359 };
2360
2361 let result = executor.execute_with_context(&call, ctx).await;
2362 assert!(
2363 matches!(result, Err(ToolError::Execution(ref message)) if message.contains("explicit policy")),
2364 "explicit deny must beat bypass: {result:?}"
2365 );
2366 assert!(!path.exists());
2367 }
2368
2369 #[tokio::test]
2370 async fn test_explicit_delete_deny_overrides_bypass() {
2371 let config = Arc::new(crate::permission::PermissionConfig::new());
2372 config.add_rule(crate::permission::PermissionRule::new(
2373 crate::permission::PermissionType::DeleteOperation,
2374 "rm child-to-preserve",
2375 false,
2376 ));
2377 let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
2378 let executor = BuiltinToolExecutorBuilder::new()
2379 .with_tool(BashTool::new())
2380 .expect("register Bash tool")
2381 .with_permission_checker(checker)
2382 .build();
2383 let call = make_tool_call("Bash", json!({"command": "rm child-to-preserve"}));
2384 let ctx = ToolExecutionContext {
2385 executing_supervisor: None,
2386 session_id: Some("s-explicit-delete-deny"),
2387 root_session_id: None,
2388 tool_call_id: &call.id,
2389 event_tx: None,
2390 available_tool_schemas: None,
2391 bypass_permissions: true,
2392 auto_approve_permissions: false,
2393 plan_read_only: false,
2394 can_async_resume: false,
2395 bash_completion_sink: None,
2396 pre_parsed_args: None,
2397 };
2398
2399 let result = executor.execute_with_context(&call, ctx).await;
2400 assert!(
2401 matches!(result, Err(ToolError::Execution(ref message)) if message.contains("explicit policy")),
2402 "explicit delete deny must beat bypass: {result:?}"
2403 );
2404 }
2405
2406 #[tokio::test]
2407 async fn plan_auto_denies_mutation_but_allows_read_without_a_checker() {
2408 let executor = BuiltinToolExecutor::new();
2409 let dir = tempfile::tempdir().unwrap();
2410 let path = dir.path().join("plan-auto.txt");
2411 let write = make_tool_call(
2412 "Write",
2413 json!({"file_path": path, "content": "must not run"}),
2414 );
2415 let write_ctx = ToolExecutionContext {
2416 executing_supervisor: None,
2417 session_id: Some("plan-auto"),
2418 root_session_id: None,
2419 tool_call_id: &write.id,
2420 event_tx: None,
2421 available_tool_schemas: None,
2422 bypass_permissions: false,
2423 auto_approve_permissions: true,
2424 plan_read_only: true,
2425 can_async_resume: false,
2426 bash_completion_sink: None,
2427 pre_parsed_args: None,
2428 };
2429 let denied = executor.execute_with_context(&write, write_ctx).await;
2430 assert!(matches!(
2431 denied,
2432 Err(ToolError::Execution(ref message)) if message.contains("Plan mode")
2433 ));
2434 assert!(tokio::fs::metadata(&path).await.is_err());
2435
2436 tokio::fs::write(&path, "readable").await.unwrap();
2437 let read = make_tool_call("Read", json!({"file_path": path}));
2438 let read_ctx = ToolExecutionContext {
2439 executing_supervisor: None,
2440 session_id: Some("plan-auto"),
2441 root_session_id: None,
2442 tool_call_id: &read.id,
2443 event_tx: None,
2444 available_tool_schemas: None,
2445 bypass_permissions: false,
2446 auto_approve_permissions: true,
2447 plan_read_only: true,
2448 can_async_resume: false,
2449 bash_completion_sink: None,
2450 pre_parsed_args: None,
2451 };
2452 let allowed = executor
2453 .execute_with_context(&read, read_ctx)
2454 .await
2455 .unwrap();
2456 assert!(allowed.success);
2457 }
2458
2459 #[tokio::test]
2460 async fn auto_request_permissions_fails_without_creating_a_pause() {
2461 let executor = BuiltinToolExecutor::new();
2462 let (event_tx, mut event_rx) = mpsc::channel(4);
2463 let call = make_tool_call("request_permissions", json!({}));
2464 let ctx = ToolExecutionContext {
2465 executing_supervisor: None,
2466 session_id: Some("auto-no-prompt"),
2467 root_session_id: None,
2468 tool_call_id: &call.id,
2469 event_tx: Some(&event_tx),
2470 available_tool_schemas: None,
2471 bypass_permissions: false,
2472 auto_approve_permissions: true,
2473 plan_read_only: false,
2474 can_async_resume: false,
2475 bash_completion_sink: None,
2476 pre_parsed_args: None,
2477 };
2478
2479 let result = executor.execute_with_context_outcome(&call, ctx).await;
2480 assert!(matches!(
2481 result,
2482 Err(ToolError::Execution(ref message)) if message.contains("cannot request expanded permissions")
2483 ));
2484 assert!(event_rx.try_recv().is_err());
2485 }
2486
2487 #[tokio::test]
2488 async fn interactive_gate_returns_synthesized_approval_pause() {
2489 let config = Arc::new(crate::permission::PermissionConfig::new());
2497 config.set_ask_rules(["Write(/etc/**)".to_string()]);
2498 config.register_session_workspace("s-interactive", "/workspace/project");
2499 let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
2500 let executor = make_executor(Some(checker));
2501
2502 let (tx, mut rx) = mpsc::channel(8);
2503 let call = make_tool_call(
2504 "Write",
2505 json!({"file_path": "/etc/gated.conf", "content": "x"}),
2506 );
2507 let ctx = ToolExecutionContext {
2508 executing_supervisor: None,
2509 session_id: Some("s-interactive"),
2510 root_session_id: None,
2511 tool_call_id: &call.id,
2512 event_tx: Some(&tx),
2513 available_tool_schemas: None,
2514 bypass_permissions: false,
2515 auto_approve_permissions: false,
2516 plan_read_only: false,
2517 can_async_resume: false,
2518 bash_completion_sink: None,
2519 pre_parsed_args: None,
2520 };
2521
2522 let result = executor
2523 .execute_with_context(&call, ctx)
2524 .await
2525 .expect("interactive gate should pause (Ok), not error");
2526
2527 assert_eq!(
2528 result.display_preference.as_deref(),
2529 Some("request_permissions"),
2530 "interactive gate must return the request_permissions pause result"
2531 );
2532 assert!(result.result.contains("awaiting_permission_approval"));
2533 let payload: serde_json::Value = serde_json::from_str(&result.result).expect("payload");
2534 let request = &payload["permission_request"];
2535 assert_eq!(request["request_id"], call.id);
2536 assert_eq!(request["session_id"], "s-interactive");
2537 assert_eq!(request["workspace_path"], "/workspace/project");
2538 assert_eq!(request["reason_code"], "configured_always_ask");
2539 assert_eq!(
2540 request["allowed_decisions"],
2541 json!(["allow_once", "deny_once"])
2542 );
2543 assert_eq!(payload["options"], json!(["Approve", "Deny"]));
2544 assert!(fs::metadata("/etc/gated.conf").await.is_err());
2545
2546 let ev = rx.recv().await.expect("approval event should be emitted");
2547 assert!(
2548 matches!(ev, AgentEvent::ToolApprovalRequested { tool_name, .. } if tool_name == "Write")
2549 );
2550 }
2551
2552 #[tokio::test]
2553 async fn proactive_permission_batch_uses_typed_remembered_scopes_then_completes() {
2554 let config = Arc::new(crate::permission::PermissionConfig::new());
2555 config.set_session_workspace("proactive-session", Some("/workspace/project".to_string()));
2556 let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(
2557 config.clone(),
2558 ));
2559 let executor = BuiltinToolExecutorBuilder::new()
2560 .with_tool(crate::tools::RequestPermissionsTool::new())
2561 .expect("register request_permissions")
2562 .with_permission_checker(checker)
2563 .build();
2564 let call = make_tool_call(
2565 "request_permissions",
2566 json!({
2567 "reason": "Deploy the service",
2568 "permissions": [
2569 {
2570 "type": "execute_command",
2571 "resource": "docker compose up -d"
2572 },
2573 {
2574 "type": "http_request",
2575 "resource": "registry.example.com"
2576 }
2577 ]
2578 }),
2579 );
2580 let (event_tx, _event_rx) = mpsc::channel(8);
2581
2582 let first = executor
2583 .execute_with_context(
2584 &call,
2585 ToolExecutionContext {
2586 executing_supervisor: None,
2587 session_id: Some("proactive-session"),
2588 root_session_id: None,
2589 tool_call_id: &call.id,
2590 event_tx: Some(&event_tx),
2591 available_tool_schemas: None,
2592 bypass_permissions: false,
2593 auto_approve_permissions: false,
2594 plan_read_only: false,
2595 can_async_resume: false,
2596 bash_completion_sink: None,
2597 pre_parsed_args: None,
2598 },
2599 )
2600 .await
2601 .expect("first batch context pauses");
2602 let first_payload: serde_json::Value = serde_json::from_str(&first.result).unwrap();
2603 let first_request = &first_payload["permission_request"];
2604 assert_eq!(first_request["resource"], "docker compose up -d");
2605 assert!(!first_request["allowed_decisions"]
2606 .as_array()
2607 .unwrap()
2608 .contains(&json!("allow_once")));
2609 assert!(first_request["allowed_decisions"]
2610 .as_array()
2611 .unwrap()
2612 .contains(&json!("allow_session")));
2613 let first_matcher: crate::permission::PermissionMatcher =
2614 serde_json::from_value(first_request["suggested_matchers"][0].clone()).unwrap();
2615 config
2616 .grant_typed_scoped_session_permission(
2617 "proactive-session",
2618 crate::permission::PermissionType::ExecuteCommand,
2619 first_matcher,
2620 )
2621 .unwrap();
2622
2623 let second = executor
2624 .execute_with_context(
2625 &call,
2626 ToolExecutionContext {
2627 executing_supervisor: None,
2628 session_id: Some("proactive-session"),
2629 root_session_id: None,
2630 tool_call_id: &call.id,
2631 event_tx: Some(&event_tx),
2632 available_tool_schemas: None,
2633 bypass_permissions: false,
2634 auto_approve_permissions: false,
2635 plan_read_only: false,
2636 can_async_resume: false,
2637 bash_completion_sink: None,
2638 pre_parsed_args: None,
2639 },
2640 )
2641 .await
2642 .expect("second batch context pauses");
2643 let second_payload: serde_json::Value = serde_json::from_str(&second.result).unwrap();
2644 let second_request = &second_payload["permission_request"];
2645 assert_eq!(second_request["resource"], "registry.example.com");
2646 let second_matcher: crate::permission::PermissionMatcher =
2647 serde_json::from_value(second_request["suggested_matchers"][0].clone()).unwrap();
2648 config
2649 .grant_typed_scoped_session_permission(
2650 "proactive-session",
2651 crate::permission::PermissionType::HttpRequest,
2652 second_matcher,
2653 )
2654 .unwrap();
2655
2656 let completed = executor
2657 .execute_with_context(
2658 &call,
2659 ToolExecutionContext {
2660 executing_supervisor: None,
2661 session_id: Some("proactive-session"),
2662 root_session_id: None,
2663 tool_call_id: &call.id,
2664 event_tx: Some(&event_tx),
2665 available_tool_schemas: None,
2666 bypass_permissions: false,
2667 auto_approve_permissions: false,
2668 plan_read_only: false,
2669 can_async_resume: false,
2670 bash_completion_sink: None,
2671 pre_parsed_args: None,
2672 },
2673 )
2674 .await
2675 .expect("all authorized contexts complete the tool");
2676 assert!(completed.display_preference.is_none());
2677 let completed_payload: serde_json::Value = serde_json::from_str(&completed.result).unwrap();
2678 assert_eq!(completed_payload["status"], "permissions_authorized");
2679 assert_eq!(
2680 completed_payload["permissions"].as_array().unwrap().len(),
2681 2
2682 );
2683 }
2684
2685 #[tokio::test]
2686 async fn workspace_permission_scope_uses_only_registered_session_identity() {
2687 let registered = Arc::new(crate::permission::PermissionConfig::new());
2688 registered.register_session_workspace("registered", "/workspace/authoritative");
2689 let registered_executor = make_executor(Some(Arc::new(
2690 crate::permission::ConfigPermissionChecker::new(registered.clone()),
2691 )));
2692
2693 let first = permission_request_payload(
2694 ®istered_executor,
2695 "registered",
2696 json!({
2697 "file_path": "/tmp/first.txt",
2698 "content": "x",
2699 "cwd": "/model/chosen-a",
2700 "workspace_path": "/model/chosen-b"
2701 }),
2702 )
2703 .await;
2704 let second = permission_request_payload(
2705 ®istered_executor,
2706 "registered",
2707 json!({
2708 "file_path": "/tmp/second.txt",
2709 "content": "x",
2710 "cwd": "/model/chosen-c"
2711 }),
2712 )
2713 .await;
2714 for payload in [&first, &second] {
2715 let request = &payload["permission_request"];
2716 assert_eq!(request["workspace_path"], "/workspace/authoritative");
2717 assert!(request["allowed_decisions"]
2718 .as_array()
2719 .unwrap()
2720 .contains(&json!("allow_workspace")));
2721 }
2722
2723 registered.set_session_workspace("registered", None);
2724 let unbound = permission_request_payload(
2725 ®istered_executor,
2726 "registered",
2727 json!({
2728 "file_path": "/tmp/unbound.txt",
2729 "content": "x",
2730 "cwd": "/workspace/authoritative"
2731 }),
2732 )
2733 .await;
2734 assert!(unbound["permission_request"]["workspace_path"].is_null());
2735 assert!(!unbound["permission_request"]["allowed_decisions"]
2736 .as_array()
2737 .unwrap()
2738 .contains(&json!("allow_workspace")));
2739
2740 registered.set_session_workspace("registered", Some("/workspace/rebound".to_string()));
2741 let rebound = permission_request_payload(
2742 ®istered_executor,
2743 "registered",
2744 json!({
2745 "file_path": "/tmp/rebound.txt",
2746 "content": "x",
2747 "workspace_path": "/workspace/authoritative"
2748 }),
2749 )
2750 .await;
2751 assert_eq!(
2752 rebound["permission_request"]["workspace_path"],
2753 "/workspace/rebound"
2754 );
2755
2756 let unregistered = Arc::new(crate::permission::PermissionConfig::new());
2757 let unregistered_executor = make_executor(Some(Arc::new(
2758 crate::permission::ConfigPermissionChecker::new(unregistered),
2759 )));
2760 let payload = permission_request_payload(
2761 &unregistered_executor,
2762 "unregistered",
2763 json!({
2764 "file_path": "/tmp/unregistered.txt",
2765 "content": "x",
2766 "cwd": "/model/chosen",
2767 "workspace_path": "/also/model/chosen"
2768 }),
2769 )
2770 .await;
2771 let request = &payload["permission_request"];
2772 assert!(request["workspace_path"].is_null());
2773 assert!(!request["allowed_decisions"]
2774 .as_array()
2775 .unwrap()
2776 .contains(&json!("allow_workspace")));
2777 }
2778
2779 #[tokio::test]
2780 async fn check_permissions_for_returns_none_when_permitted() {
2781 let executor = make_executor(None);
2784 let call = make_tool_call("Read", json!({"file_path": "/tmp/whatever"}));
2785 let ctx = ToolExecutionContext::none(&call.id);
2786 let decision = executor
2787 .check_permissions_for(&call, &ctx)
2788 .await
2789 .expect("no checker means no gate");
2790 assert!(decision.is_none(), "no checker must yield Ok(None)");
2791 }
2792
2793 struct HostStub {
2796 approve: bool,
2797 }
2798
2799 #[async_trait]
2800 impl crate::approval::ApprovalProxy for HostStub {
2801 async fn request_approval(&self, _ask: crate::approval::ApprovalAsk) -> bool {
2802 self.approve
2803 }
2804 }
2805
2806 #[tokio::test]
2807 async fn approval_proxy_grant_lets_gated_tool_proceed() {
2808 let dir = tempfile::tempdir().unwrap();
2813 let path = dir.path().join("approved.txt");
2814 let path_str = path.to_str().unwrap().to_string();
2815 let config = Arc::new(crate::permission::PermissionConfig::new());
2816 config.set_ask_rules([format!("Write({}/**)", dir.path().to_str().unwrap())]);
2817 let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
2818 let executor = make_executor(Some(checker));
2819
2820 let call = make_tool_call("Write", json!({"file_path": path_str, "content": "ok"}));
2821 let ctx = ToolExecutionContext {
2822 executing_supervisor: None,
2823 session_id: Some("s-worker"),
2824 root_session_id: None,
2825 tool_call_id: &call.id,
2826 event_tx: None,
2827 available_tool_schemas: None,
2828 bypass_permissions: false,
2829 auto_approve_permissions: false,
2830 plan_read_only: false,
2831 can_async_resume: false,
2832 bash_completion_sink: None,
2833 pre_parsed_args: None,
2834 };
2835
2836 let proxy: Arc<dyn crate::approval::ApprovalProxy> = Arc::new(HostStub { approve: true });
2837 let result = crate::approval::with_approval_proxy(
2838 Some(proxy),
2839 executor.execute_with_context(&call, ctx),
2840 )
2841 .await;
2842
2843 assert!(
2844 result.is_ok(),
2845 "host grant should let the write through: {result:?}"
2846 );
2847 assert_eq!(fs::read_to_string(&path).await.unwrap(), "ok");
2848 }
2849
2850 #[tokio::test]
2851 async fn approval_proxy_deny_fails_gated_tool_closed() {
2852 let dir = tempfile::tempdir().unwrap();
2855 let path = dir.path().join("denied.txt");
2856 let path_str = path.to_str().unwrap().to_string();
2857 let config = Arc::new(crate::permission::PermissionConfig::new());
2858 config.set_ask_rules([format!("Write({}/**)", dir.path().to_str().unwrap())]);
2859 let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
2860 let executor = make_executor(Some(checker));
2861
2862 let call = make_tool_call("Write", json!({"file_path": path_str, "content": "nope"}));
2863 let ctx = ToolExecutionContext {
2864 executing_supervisor: None,
2865 session_id: Some("s-worker"),
2866 root_session_id: None,
2867 tool_call_id: &call.id,
2868 event_tx: None,
2869 available_tool_schemas: None,
2870 bypass_permissions: false,
2871 auto_approve_permissions: false,
2872 plan_read_only: false,
2873 can_async_resume: false,
2874 bash_completion_sink: None,
2875 pre_parsed_args: None,
2876 };
2877
2878 let proxy: Arc<dyn crate::approval::ApprovalProxy> = Arc::new(HostStub { approve: false });
2879 let result = crate::approval::with_approval_proxy(
2880 Some(proxy),
2881 executor.execute_with_context(&call, ctx),
2882 )
2883 .await;
2884
2885 assert!(
2886 matches!(result, Err(ToolError::Execution(ref m)) if m.contains("denied by host")),
2887 "host deny should fail the tool closed: {result:?}"
2888 );
2889 assert!(fs::metadata(&path).await.is_err());
2890 }
2891
2892 #[tokio::test]
2893 async fn tool_can_stream_events_via_execute_with_context() {
2894 struct StreamingTool;
2895
2896 #[async_trait]
2897 impl Tool for StreamingTool {
2898 fn name(&self) -> &str {
2899 "streaming_tool"
2900 }
2901
2902 fn description(&self) -> &str {
2903 "streams one token"
2904 }
2905
2906 fn parameters_schema(&self) -> serde_json::Value {
2907 json!({"type":"object","properties":{}})
2908 }
2909
2910 async fn invoke(
2911 &self,
2912 _args: serde_json::Value,
2913 ctx: ToolCtx,
2914 ) -> Result<ToolOutcome, ToolError> {
2915 ctx.emit(AgentEvent::Token {
2916 content: "stream".to_string(),
2917 })
2918 .await;
2919 Ok(ToolOutcome::Completed(ToolResult {
2920 success: true,
2921 result: "ok".to_string(),
2922 display_preference: None,
2923 images: Vec::new(),
2924 }))
2925 }
2926 }
2927
2928 let executor = BuiltinToolExecutor::new();
2929 executor
2930 .register_tool(StreamingTool)
2931 .expect("register streaming tool");
2932
2933 let (tx, mut rx) = mpsc::channel(8);
2934 let call = make_tool_call("streaming_tool", json!({}));
2935
2936 let result = executor
2937 .execute_with_context(
2938 &call,
2939 ToolExecutionContext {
2940 executing_supervisor: None,
2941 session_id: Some("s1"),
2942 root_session_id: None,
2943 tool_call_id: &call.id,
2944 event_tx: Some(&tx),
2945 available_tool_schemas: None,
2946 bypass_permissions: false,
2947 auto_approve_permissions: false,
2948 plan_read_only: false,
2949 can_async_resume: false,
2950 bash_completion_sink: None,
2951 pre_parsed_args: None,
2952 },
2953 )
2954 .await
2955 .expect("execute tool");
2956
2957 assert!(result.success);
2958 assert_eq!(result.result, "ok");
2959
2960 let ev = rx.recv().await.expect("expected streamed event");
2961 assert!(
2962 matches!(ev, AgentEvent::ToolToken { tool_call_id, content } if tool_call_id == "call_1" && content == "stream")
2963 );
2964 }
2965
2966 #[tokio::test]
2967 async fn removed_legacy_tools_return_not_found() {
2968 let executor = BuiltinToolExecutor::new();
2969
2970 for legacy in ["claude_code", "search_in_file", "search_in_project"] {
2971 let call = make_tool_call(legacy, json!({}));
2972 let result = executor.execute(&call).await;
2973 assert!(matches!(result, Err(ToolError::NotFound(_))));
2974 }
2975 }
2976
2977 #[tokio::test]
2978 async fn executor_prefers_exact_tool_name_before_builtin_alias() {
2979 struct CustomSpawnSessionTool;
2980
2981 #[async_trait]
2982 impl Tool for CustomSpawnSessionTool {
2983 fn name(&self) -> &str {
2984 "spawn_session"
2985 }
2986
2987 fn description(&self) -> &str {
2988 "custom tool for regression coverage"
2989 }
2990
2991 fn parameters_schema(&self) -> serde_json::Value {
2992 json!({"type":"object","properties":{}})
2993 }
2994
2995 async fn invoke(
2996 &self,
2997 _args: serde_json::Value,
2998 _ctx: ToolCtx,
2999 ) -> Result<ToolOutcome, ToolError> {
3000 Ok(ToolOutcome::Completed(ToolResult {
3001 success: true,
3002 result: "custom-spawn-session".to_string(),
3003 display_preference: None,
3004 images: Vec::new(),
3005 }))
3006 }
3007 }
3008
3009 let executor = BuiltinToolExecutorBuilder::new()
3010 .with_tool(CustomSpawnSessionTool)
3011 .expect("register custom spawn_session tool")
3012 .build();
3013
3014 let call = make_tool_call("spawn_session", json!({}));
3015 let result = executor.execute(&call).await.expect("execute custom tool");
3016 assert!(result.success);
3017 assert_eq!(result.result, "custom-spawn-session");
3018 }
3019
3020 struct ExactRoutingTool {
3021 name: &'static str,
3022 label: &'static str,
3023 args_sensitive: bool,
3024 }
3025
3026 #[async_trait]
3027 impl Tool for ExactRoutingTool {
3028 fn name(&self) -> &str {
3029 self.name
3030 }
3031
3032 fn description(&self) -> &str {
3033 "exact routing regression tool"
3034 }
3035
3036 fn parameters_schema(&self) -> serde_json::Value {
3037 json!({"type":"object","properties":{}})
3038 }
3039
3040 fn classify(&self, args: &serde_json::Value) -> bamboo_agent_core::ToolClass {
3041 let has_builtin_normalized_arg = ["file_path", "command", "pattern"]
3042 .iter()
3043 .any(|key| args.get(key).is_some());
3044 if self.args_sensitive && !has_builtin_normalized_arg {
3045 bamboo_agent_core::ToolClass::READONLY_PARALLEL
3046 } else {
3047 bamboo_agent_core::ToolClass::MUTATING_SERIAL
3048 }
3049 }
3050
3051 async fn invoke(
3052 &self,
3053 args: serde_json::Value,
3054 _ctx: ToolCtx,
3055 ) -> Result<ToolOutcome, ToolError> {
3056 Ok(ToolOutcome::Completed(ToolResult {
3057 success: true,
3058 result: json!({"label": self.label, "args": args}).to_string(),
3059 display_preference: None,
3060 images: Vec::new(),
3061 }))
3062 }
3063 }
3064
3065 #[tokio::test]
3066 async fn executor_preserves_namespaced_exact_identity_and_unqualified_collision() {
3067 let executor = BuiltinToolExecutorBuilder::new()
3068 .with_tool(ExactRoutingTool {
3069 name: "a::custom_tool",
3070 label: "namespaced",
3071 args_sensitive: false,
3072 })
3073 .expect("register namespaced tool")
3074 .with_tool(ExactRoutingTool {
3075 name: "custom_tool",
3076 label: "unqualified",
3077 args_sensitive: false,
3078 })
3079 .expect("register unqualified tool")
3080 .build();
3081
3082 assert!(executor.owns_exact_tool("a::custom_tool"));
3083 assert!(executor.owns_exact_tool("custom_tool"));
3084 assert!(!executor.owns_exact_tool("A::custom_tool"));
3085 let names: Vec<String> = executor
3086 .list_tools()
3087 .into_iter()
3088 .map(|schema| schema.function.name)
3089 .collect();
3090 assert!(names.contains(&"a::custom_tool".to_string()));
3091 assert!(names.contains(&"custom_tool".to_string()));
3092
3093 let namespaced = executor
3094 .execute(&make_tool_call("a::custom_tool", json!({})))
3095 .await
3096 .expect("execute namespaced exact tool");
3097 let unqualified = executor
3098 .execute(&make_tool_call("custom_tool", json!({})))
3099 .await
3100 .expect("execute unqualified exact tool");
3101 assert_eq!(
3102 serde_json::from_str::<serde_json::Value>(&namespaced.result).unwrap()["label"],
3103 "namespaced"
3104 );
3105 assert_eq!(
3106 serde_json::from_str::<serde_json::Value>(&unqualified.result).unwrap()["label"],
3107 "unqualified"
3108 );
3109 }
3110
3111 #[tokio::test]
3112 async fn exact_canonical_shadows_do_not_inherit_builtin_argument_provenance() {
3113 let executor = BuiltinToolExecutorBuilder::new()
3114 .with_tool(ExactRoutingTool {
3115 name: "Read",
3116 label: "exact-read",
3117 args_sensitive: true,
3118 })
3119 .expect("register exact Read shadow")
3120 .with_tool(ExactRoutingTool {
3121 name: "Write",
3122 label: "exact-write",
3123 args_sensitive: true,
3124 })
3125 .expect("register exact Write shadow")
3126 .with_tool(ExactRoutingTool {
3127 name: "Edit",
3128 label: "exact-edit",
3129 args_sensitive: true,
3130 })
3131 .expect("register exact Edit shadow")
3132 .with_tool(ExactRoutingTool {
3133 name: "Bash",
3134 label: "exact-bash",
3135 args_sensitive: true,
3136 })
3137 .expect("register exact Bash shadow")
3138 .with_tool(ExactRoutingTool {
3139 name: "Glob",
3140 label: "exact-glob",
3141 args_sensitive: true,
3142 })
3143 .expect("register exact Glob shadow")
3144 .with_default_tools()
3145 .build();
3146
3147 let cases = [
3148 ("Read", json!({"path": "/tmp/custom-read"}), "file_path"),
3149 ("Write", json!({"path": "/tmp/custom-write"}), "file_path"),
3150 ("Edit", json!({"path": "/tmp/custom-edit"}), "file_path"),
3151 ("Bash", json!({"cmd": "custom-command"}), "command"),
3152 (
3153 "Glob",
3154 json!({"path": "/tmp/custom-glob", "recursive": true}),
3155 "pattern",
3156 ),
3157 ];
3158
3159 for (name, args, normalized_key) in cases {
3160 let call = make_tool_call(name, args.clone());
3161 assert_eq!(
3162 executor.call_mutability(&call),
3163 crate::ToolMutability::ReadOnly,
3164 "custom {name} classification must see the original args"
3165 );
3166 assert!(
3167 executor.call_concurrency_safe(&call),
3168 "custom {name} classification must remain parallel-safe"
3169 );
3170
3171 let result = executor
3172 .execute(&call)
3173 .await
3174 .unwrap_or_else(|error| panic!("execute custom {name}: {error}"));
3175 let result: serde_json::Value = serde_json::from_str(&result.result).unwrap();
3176 assert_eq!(result["args"], args, "custom {name} args changed");
3177 assert!(result["args"].get(normalized_key).is_none());
3178 }
3179
3180 let permission_executor = BuiltinToolExecutorBuilder::new()
3185 .with_tool(ExactRoutingTool {
3186 name: "Read",
3187 label: "permission-read",
3188 args_sensitive: true,
3189 })
3190 .expect("register permission-aware Read shadow")
3191 .with_tool(ExactRoutingTool {
3192 name: "Glob",
3193 label: "permission-glob",
3194 args_sensitive: true,
3195 })
3196 .expect("register permission-aware Glob shadow")
3197 .with_default_tools()
3198 .with_permission_checker(Arc::new(crate::permission::AllowAllPermissionChecker))
3199 .build();
3200 for (name, args) in [
3201 ("Read", json!({"path": "/tmp/permission-read"})),
3202 (
3203 "Glob",
3204 json!({"path": "/tmp/permission-glob", "recursive": true}),
3205 ),
3206 ] {
3207 let call = make_tool_call(name, args.clone());
3208 let ctx = ToolExecutionContext::none(&call.id);
3209 assert!(permission_executor
3210 .check_permissions_for(&call, &ctx)
3211 .await
3212 .expect("permission check")
3213 .is_none());
3214 assert_eq!(
3215 permission_executor.call_mutability(&call),
3216 crate::ToolMutability::ReadOnly
3217 );
3218 assert!(permission_executor.call_concurrency_safe(&call));
3219 let result = permission_executor.execute(&call).await.unwrap();
3220 let result: serde_json::Value = serde_json::from_str(&result.result).unwrap();
3221 assert_eq!(result["args"], args);
3222 }
3223 }
3224
3225 #[tokio::test]
3226 async fn exact_apply_patch_keeps_original_args_and_classification() {
3227 let executor = BuiltinToolExecutorBuilder::new()
3228 .with_filesystem_tool("Edit")
3229 .expect("register builtin Edit")
3230 .with_tool(ExactRoutingTool {
3231 name: "apply_patch",
3232 label: "exact-apply-patch",
3233 args_sensitive: true,
3234 })
3235 .expect("register exact apply_patch shadow")
3236 .build();
3237 let call = make_tool_call("apply_patch", json!({"path": "/tmp/exact-shadow"}));
3238
3239 let (mutability, parallel_safe) = executor.call_parallel_classification(&call);
3240 assert_eq!(mutability, crate::ToolMutability::ReadOnly);
3241 assert!(parallel_safe);
3242
3243 let result = executor.execute(&call).await.expect("execute exact shadow");
3244 let result: serde_json::Value = serde_json::from_str(&result.result).unwrap();
3245 assert_eq!(result["label"], "exact-apply-patch");
3246 assert_eq!(result["args"]["path"], "/tmp/exact-shadow");
3247 assert!(result["args"].get("file_path").is_none());
3248 }
3249
3250 #[tokio::test]
3251 async fn exact_permission_seam_preserves_default_apply_patch_builtin_provenance() {
3252 let executor = BuiltinToolExecutorBuilder::new()
3253 .with_filesystem_tool("Edit")
3254 .expect("register builtin Edit")
3255 .with_permission_checker(Arc::new(crate::permission::AllowAllPermissionChecker))
3256 .build();
3257 let raw_args = json!({
3258 "path": "/tmp/exact-permission-apply-patch.txt",
3259 "old_string": "before",
3260 "new_string": "after"
3261 });
3262 let call = make_tool_call("default::apply_patch", raw_args.clone());
3263 let ctx = ToolExecutionContext {
3264 pre_parsed_args: Some(&raw_args),
3265 ..ToolExecutionContext::none(&call.id)
3266 };
3267
3268 assert!(executor
3269 .check_permissions_for_exact(&call, "Edit", &ctx)
3270 .await
3271 .expect("normalized builtin permission check")
3272 .is_none());
3273 assert_eq!(call.function.name, "default::apply_patch");
3274 assert_eq!(
3275 serde_json::from_str::<serde_json::Value>(&call.function.arguments).unwrap(),
3276 raw_args
3277 );
3278 }
3279
3280 #[tokio::test]
3281 async fn unshadowed_alias_and_namespace_keep_legacy_argument_compatibility() {
3282 let dir = tempfile::tempdir().unwrap();
3283 let path = dir.path().join("legacy-alias.txt");
3284 fs::write(&path, "before").await.unwrap();
3285 let executor = BuiltinToolExecutorBuilder::new()
3286 .with_filesystem_tool("Edit")
3287 .expect("register builtin Edit")
3288 .with_permission_checker(Arc::new(crate::permission::AllowAllPermissionChecker))
3289 .build();
3290
3291 let result = executor
3292 .execute(&make_tool_call(
3293 "default::apply_patch",
3294 json!({
3295 "path": path,
3296 "old_string": "before",
3297 "new_string": "after"
3298 }),
3299 ))
3300 .await
3301 .expect("execute unshadowed alias");
3302 assert!(result.success);
3303 assert_eq!(fs::read_to_string(path).await.unwrap(), "after");
3304 }
3305
3306 struct EchoArgsTool;
3311
3312 #[async_trait]
3313 impl Tool for EchoArgsTool {
3314 fn name(&self) -> &str {
3315 "echo_args"
3316 }
3317 fn description(&self) -> &str {
3318 "echoes the `v` arg"
3319 }
3320 fn parameters_schema(&self) -> serde_json::Value {
3321 json!({"type":"object","properties":{"v":{"type":"string"}}})
3322 }
3323 async fn invoke(
3324 &self,
3325 args: serde_json::Value,
3326 _ctx: ToolCtx,
3327 ) -> Result<ToolOutcome, ToolError> {
3328 let v = args
3329 .get("v")
3330 .and_then(serde_json::Value::as_str)
3331 .unwrap_or("<none>")
3332 .to_string();
3333 Ok(ToolOutcome::Completed(ToolResult {
3334 success: true,
3335 result: v,
3336 display_preference: None,
3337 images: Vec::new(),
3338 }))
3339 }
3340 }
3341
3342 fn ctx_with_pre_parsed<'a>(
3343 call_id: &'a str,
3344 pre_parsed: Option<&'a serde_json::Value>,
3345 ) -> ToolExecutionContext<'a> {
3346 ToolExecutionContext {
3347 executing_supervisor: None,
3348 session_id: Some("s-106"),
3349 root_session_id: None,
3350 tool_call_id: call_id,
3351 event_tx: None,
3352 available_tool_schemas: None,
3353 bypass_permissions: false,
3354 auto_approve_permissions: false,
3355 plan_read_only: false,
3356 can_async_resume: false,
3357 bash_completion_sink: None,
3358 pre_parsed_args: pre_parsed,
3359 }
3360 }
3361
3362 #[tokio::test]
3363 async fn execute_with_context_reuses_pre_parsed_args_without_reparsing() {
3364 let executor = BuiltinToolExecutor::new();
3370 executor.register_tool(EchoArgsTool).expect("register echo");
3371
3372 let call = make_tool_call("echo_args", json!({"v": "raw"}));
3373 let pre_parsed = json!({"v": "preparsed"});
3374 let ctx = ctx_with_pre_parsed(&call.id, Some(&pre_parsed));
3375
3376 let result = executor
3377 .execute_with_context(&call, ctx)
3378 .await
3379 .expect("execute echo tool");
3380 assert_eq!(
3381 result.result, "preparsed",
3382 "executor must reuse pre_parsed_args, not re-parse the raw string"
3383 );
3384 }
3385
3386 #[tokio::test]
3387 async fn execute_with_context_parses_raw_when_no_pre_parsed_args() {
3388 let executor = BuiltinToolExecutor::new();
3392 executor.register_tool(EchoArgsTool).expect("register echo");
3393
3394 let call = make_tool_call("echo_args", json!({"v": "raw"}));
3395 let ctx = ctx_with_pre_parsed(&call.id, None);
3396
3397 let result = executor
3398 .execute_with_context(&call, ctx)
3399 .await
3400 .expect("execute echo tool");
3401 assert_eq!(
3402 result.result, "raw",
3403 "without pre_parsed_args the executor parses the raw string as before"
3404 );
3405 }
3406
3407 #[tokio::test]
3408 async fn execute_with_context_malformed_args_repair_unchanged_without_pre_parsed() {
3409 let dir = tempfile::tempdir().unwrap();
3413 let path = dir.path().join("recovered-no-preparsed.txt");
3414 let malformed_args = format!(
3415 r#"{{"file_path":"{}","content":"recovered content""#,
3416 path.display()
3417 );
3418
3419 let executor = BuiltinToolExecutor::new();
3420 let call = make_tool_call_with_raw_args("Write", &malformed_args);
3421 let ctx = ctx_with_pre_parsed(&call.id, None);
3422
3423 let result = executor
3424 .execute_with_context(&call, ctx)
3425 .await
3426 .expect("truncated JSON should be auto-repaired");
3427 assert!(result.success);
3428 let written = fs::read_to_string(&path).await.expect("file written");
3429 assert_eq!(written, "recovered content");
3430 }
3431
3432 #[tokio::test]
3433 async fn successful_write_emits_one_bounded_file_changed_event() {
3434 let dir = tempfile::tempdir().unwrap();
3435 let path = dir.path().join("write-event.txt");
3436 let path_string = path.to_string_lossy().into_owned();
3437 let padded_path = format!(" {path_string} ");
3438 let recorder = Arc::new(InMemoryToolEventRecorder::new(4).unwrap());
3439 let executor = BuiltinToolExecutorBuilder::new()
3440 .with_filesystem_tool("Write")
3441 .unwrap()
3442 .with_tool_event_publisher(recorder.clone())
3443 .build();
3444 let call = make_tool_call_with_id(
3445 "write-call",
3446 "Write",
3447 json!({"file_path": padded_path, "content": "written"}),
3448 );
3449
3450 let result = executor
3451 .execute_with_context(
3452 &call,
3453 tool_event_context(&call, Some("write-session"), Some("write-root-session")),
3454 )
3455 .await
3456 .unwrap();
3457
3458 assert!(result.success);
3459 assert_eq!(fs::read_to_string(path).await.unwrap(), "written");
3460 assert_single_file_changed(
3461 &recorder,
3462 "write-session",
3463 "write-root-session",
3464 "Write",
3465 "write-call",
3466 &path_string,
3467 );
3468 }
3469
3470 #[tokio::test]
3471 async fn successful_edit_emits_one_bounded_file_changed_event() {
3472 let dir = tempfile::tempdir().unwrap();
3473 let path = dir.path().join("edit-event.txt");
3474 fs::write(&path, "before\n").await.unwrap();
3475 let path_string = path.to_string_lossy().into_owned();
3476 let padded_path = format!(" {path_string} ");
3477 let recorder = Arc::new(InMemoryToolEventRecorder::new(4).unwrap());
3478 let executor = BuiltinToolExecutor::new().with_tool_event_publisher(recorder.clone());
3479 let read =
3480 make_tool_call_with_id("edit-read-call", "Read", json!({"file_path": padded_path}));
3481 executor
3482 .execute_with_context(
3483 &read,
3484 tool_event_context(&read, Some("edit-session"), Some("edit-root-session")),
3485 )
3486 .await
3487 .unwrap();
3488 assert!(recorder.try_snapshot().unwrap().is_empty());
3489
3490 let edit = make_tool_call_with_id(
3491 "edit-call",
3492 "Edit",
3493 json!({
3494 "file_path": format!(" {path_string} "),
3495 "old_string": "before",
3496 "new_string": "after"
3497 }),
3498 );
3499 let result = executor
3500 .execute_with_context(
3501 &edit,
3502 tool_event_context(&edit, Some("edit-session"), Some("edit-root-session")),
3503 )
3504 .await
3505 .unwrap();
3506
3507 assert!(result.success);
3508 assert_eq!(fs::read_to_string(path).await.unwrap(), "after\n");
3509 assert_single_file_changed(
3510 &recorder,
3511 "edit-session",
3512 "edit-root-session",
3513 "Edit",
3514 "edit-call",
3515 &path_string,
3516 );
3517 }
3518
3519 #[tokio::test]
3520 async fn successful_apply_patch_alias_emits_canonical_edit_with_original_call_id() {
3521 let dir = tempfile::tempdir().unwrap();
3522 let path = dir.path().join("apply-patch-event.txt");
3523 fs::write(&path, "alpha\nbeta\n").await.unwrap();
3524 let path_string = path.to_string_lossy().into_owned();
3525 let recorder = Arc::new(InMemoryToolEventRecorder::new(4).unwrap());
3526 let executor = BuiltinToolExecutor::new().with_tool_event_publisher(recorder.clone());
3527 let read = make_tool_call_with_id(
3528 "apply-patch-read-call",
3529 "Read",
3530 json!({"file_path": path_string}),
3531 );
3532 executor
3533 .execute_with_context(
3534 &read,
3535 tool_event_context(&read, Some("alias-session"), Some("alias-root-session")),
3536 )
3537 .await
3538 .unwrap();
3539
3540 let edit = make_tool_call_with_id(
3541 "model-original-alias-call",
3542 "apply_patch",
3543 json!({
3544 "path": format!(" {path_string} "),
3545 "old_string": "beta",
3546 "new_string": "BETA"
3547 }),
3548 );
3549 let result = executor
3550 .execute_with_context(
3551 &edit,
3552 tool_event_context(&edit, Some("alias-session"), Some("alias-root-session")),
3553 )
3554 .await
3555 .unwrap();
3556
3557 assert!(result.success);
3558 assert_eq!(fs::read_to_string(path).await.unwrap(), "alpha\nBETA\n");
3559 assert_single_file_changed(
3560 &recorder,
3561 "alias-session",
3562 "alias-root-session",
3563 "Edit",
3564 "model-original-alias-call",
3565 &path_string,
3566 );
3567 }
3568
3569 #[tokio::test]
3570 async fn successful_notebook_edit_emits_one_bounded_file_changed_event() {
3571 let dir = tempfile::tempdir().unwrap();
3572 let path = dir.path().join("notebook-event.ipynb");
3573 fs::write(
3574 &path,
3575 r#"{"cells":[],"metadata":{},"nbformat":4,"nbformat_minor":5}"#,
3576 )
3577 .await
3578 .unwrap();
3579 let path_string = path.to_string_lossy().into_owned();
3580 let recorder = Arc::new(InMemoryToolEventRecorder::new(4).unwrap());
3581 let executor = BuiltinToolExecutorBuilder::new()
3582 .with_filesystem_tool("NotebookEdit")
3583 .unwrap()
3584 .with_tool_event_publisher(recorder.clone())
3585 .build();
3586 let call = make_tool_call_with_id(
3587 "notebook-call",
3588 "NotebookEdit",
3589 json!({
3590 "notebook_path": format!(" {path_string} "),
3591 "new_source": "print('hello')",
3592 "cell_type": "code",
3593 "edit_mode": "insert"
3594 }),
3595 );
3596
3597 let result = executor
3598 .execute_with_context(
3599 &call,
3600 tool_event_context(
3601 &call,
3602 Some("notebook-session"),
3603 Some("notebook-root-session"),
3604 ),
3605 )
3606 .await
3607 .unwrap();
3608
3609 assert!(result.success);
3610 assert_single_file_changed(
3611 &recorder,
3612 "notebook-session",
3613 "notebook-root-session",
3614 "NotebookEdit",
3615 "notebook-call",
3616 &path_string,
3617 );
3618 }
3619
3620 #[cfg(unix)]
3621 #[tokio::test]
3622 async fn write_through_intermediate_symlink_fails_and_emits_zero_events() {
3623 use std::os::unix::fs::symlink;
3624
3625 let workspace = tempfile::tempdir().unwrap();
3626 let external = tempfile::tempdir().unwrap();
3627 let linked_dir = workspace.path().join("linked");
3628 symlink(external.path(), &linked_dir).unwrap();
3629 let target = linked_dir.join("write.txt");
3630 let recorder = Arc::new(InMemoryToolEventRecorder::new(4).unwrap());
3631 let executor = BuiltinToolExecutorBuilder::new()
3632 .with_filesystem_tool("Write")
3633 .unwrap()
3634 .with_tool_event_publisher(recorder.clone())
3635 .build();
3636 let call = make_tool_call_with_id(
3637 "symlink-write",
3638 "Write",
3639 json!({"file_path": target, "content": "must-not-write"}),
3640 );
3641
3642 let result = executor
3643 .execute_with_context(
3644 &call,
3645 tool_event_context(&call, Some("symlink-session"), Some("symlink-root")),
3646 )
3647 .await;
3648 assert!(
3649 result.is_err() || result.as_ref().is_ok_and(|result| !result.success),
3650 "Write must fail closed through an intermediate symlink"
3651 );
3652 assert!(!external.path().join("write.txt").exists());
3653 assert!(recorder.try_snapshot().unwrap().is_empty());
3654 }
3655
3656 #[cfg(unix)]
3657 #[tokio::test]
3658 async fn edit_of_symlinked_file_fails_and_emits_zero_events() {
3659 use std::os::unix::fs::symlink;
3660
3661 let dir = tempfile::tempdir().unwrap();
3662 let real = dir.path().join("real.txt");
3663 let linked = dir.path().join("linked.txt");
3664 fs::write(&real, "before\n").await.unwrap();
3665 symlink(&real, &linked).unwrap();
3666 let recorder = Arc::new(InMemoryToolEventRecorder::new(4).unwrap());
3667 let executor = BuiltinToolExecutor::new().with_tool_event_publisher(recorder.clone());
3668 let read =
3669 make_tool_call_with_id("symlink-edit-read", "Read", json!({"file_path": linked}));
3670 let _ = executor
3671 .execute_with_context(
3672 &read,
3673 tool_event_context(&read, Some("symlink-session"), Some("symlink-root")),
3674 )
3675 .await;
3676 let edit = make_tool_call_with_id(
3677 "symlink-edit",
3678 "Edit",
3679 json!({
3680 "file_path": linked,
3681 "old_string": "before",
3682 "new_string": "after"
3683 }),
3684 );
3685
3686 let result = executor
3687 .execute_with_context(
3688 &edit,
3689 tool_event_context(&edit, Some("symlink-session"), Some("symlink-root")),
3690 )
3691 .await;
3692 assert!(
3693 result.is_err() || result.as_ref().is_ok_and(|result| !result.success),
3694 "Edit must fail closed for a symlinked final file"
3695 );
3696 assert_eq!(fs::read_to_string(&real).await.unwrap(), "before\n");
3697 assert!(recorder.try_snapshot().unwrap().is_empty());
3698 }
3699
3700 #[cfg(unix)]
3701 #[tokio::test]
3702 async fn notebook_edit_through_intermediate_symlink_fails_and_emits_zero_events() {
3703 use std::os::unix::fs::symlink;
3704
3705 let workspace = tempfile::tempdir().unwrap();
3706 let external = tempfile::tempdir().unwrap();
3707 let real_notebook = external.path().join("real.ipynb");
3708 let original = r#"{"cells":[],"metadata":{},"nbformat":4,"nbformat_minor":5}"#;
3709 fs::write(&real_notebook, original).await.unwrap();
3710 let linked_dir = workspace.path().join("linked");
3711 symlink(external.path(), &linked_dir).unwrap();
3712 let recorder = Arc::new(InMemoryToolEventRecorder::new(4).unwrap());
3713 let executor = BuiltinToolExecutorBuilder::new()
3714 .with_filesystem_tool("NotebookEdit")
3715 .unwrap()
3716 .with_tool_event_publisher(recorder.clone())
3717 .build();
3718 let call = make_tool_call_with_id(
3719 "symlink-notebook",
3720 "NotebookEdit",
3721 json!({
3722 "notebook_path": linked_dir.join("real.ipynb"),
3723 "new_source": "print('must not write')",
3724 "cell_type": "code",
3725 "edit_mode": "insert"
3726 }),
3727 );
3728
3729 let result = executor
3730 .execute_with_context(
3731 &call,
3732 tool_event_context(&call, Some("symlink-session"), Some("symlink-root")),
3733 )
3734 .await;
3735 assert!(
3736 result.is_err() || result.as_ref().is_ok_and(|result| !result.success),
3737 "NotebookEdit must fail closed through an intermediate symlink"
3738 );
3739 assert_eq!(fs::read_to_string(&real_notebook).await.unwrap(), original);
3740 assert!(recorder.try_snapshot().unwrap().is_empty());
3741 }
3742
3743 #[tokio::test]
3744 async fn failed_and_non_successful_mutations_emit_no_event() {
3745 let recorder = Arc::new(InMemoryToolEventRecorder::new(4).unwrap());
3746 let executor = BuiltinToolExecutorBuilder::new()
3747 .with_filesystem_tool("Write")
3748 .unwrap()
3749 .with_tool_event_publisher(recorder.clone())
3750 .build();
3751 let failed = make_tool_call_with_id(
3752 "failed-write-call",
3753 "Write",
3754 json!({"file_path": "relative.txt", "content": "never"}),
3755 );
3756 assert!(executor
3757 .execute_with_context(
3758 &failed,
3759 tool_event_context(
3760 &failed,
3761 Some("failure-session"),
3762 Some("failure-root-session"),
3763 ),
3764 )
3765 .await
3766 .is_err());
3767 assert!(recorder.try_snapshot().unwrap().is_empty());
3768
3769 let completed_false = marked_stub_write_executor(false, recorder.clone());
3770 let call = make_tool_call_with_id(
3771 "completed-false-call",
3772 "Write",
3773 json!({"file_path": "/valid/event/path.txt"}),
3774 );
3775 let result = completed_false
3776 .execute_with_context(
3777 &call,
3778 tool_event_context(&call, Some("failure-session"), Some("failure-root-session")),
3779 )
3780 .await
3781 .unwrap();
3782 assert!(!result.success);
3783 assert!(recorder.try_snapshot().unwrap().is_empty());
3784 }
3785
3786 #[tokio::test]
3787 async fn committed_postverify_failure_emits_no_tool_event() {
3788 let dir = tempfile::tempdir().unwrap();
3789 let path = dir.path().join("postverify-conflict.txt");
3790 fs::write(&path, "before").await.unwrap();
3791 let path_string = path.to_string_lossy().into_owned();
3792 let session_id = format!("event-conflict-{}", uuid::Uuid::new_v4());
3793 let recorder = Arc::new(InMemoryToolEventRecorder::new(4).unwrap());
3794 let executor =
3795 Arc::new(BuiltinToolExecutor::new().with_tool_event_publisher(recorder.clone()));
3796
3797 let initial_read = make_tool_call_with_id(
3798 "conflict-initial-read",
3799 "Read",
3800 json!({"file_path": path_string}),
3801 );
3802 executor
3803 .execute_with_context(
3804 &initial_read,
3805 tool_event_context(
3806 &initial_read,
3807 Some(&session_id),
3808 Some("conflict-root-session"),
3809 ),
3810 )
3811 .await
3812 .unwrap();
3813 let (advance_reached, resume_advance) =
3814 crate::tools::read_tracker::pause_next_advance_for_test(&session_id, &path_string)
3815 .await;
3816
3817 let writer_executor = executor.clone();
3818 let writer_session = session_id.clone();
3819 let writer_path = path_string.clone();
3820 let writer = tokio::spawn(async move {
3821 let call = make_tool_call_with_id(
3822 "conflict-write-call",
3823 "Write",
3824 json!({"file_path": writer_path, "content": "intended"}),
3825 );
3826 writer_executor
3827 .execute_with_context(
3828 &call,
3829 tool_event_context(&call, Some(&writer_session), Some("conflict-root-session")),
3830 )
3831 .await
3832 });
3833
3834 tokio::time::timeout(
3835 std::time::Duration::from_secs(5),
3836 advance_reached.notified(),
3837 )
3838 .await
3839 .expect("Write did not reach post-write baseline advancement");
3840 fs::write(&path, "other").await.unwrap();
3841 let concurrent_read = make_tool_call_with_id(
3842 "conflict-concurrent-read",
3843 "Read",
3844 json!({"file_path": path_string}),
3845 );
3846 executor
3847 .execute_with_context(
3848 &concurrent_read,
3849 tool_event_context(
3850 &concurrent_read,
3851 Some(&session_id),
3852 Some("conflict-root-session"),
3853 ),
3854 )
3855 .await
3856 .unwrap();
3857 fs::write(&path, "intended").await.unwrap();
3858 resume_advance.notify_one();
3859
3860 let outcome = tokio::time::timeout(std::time::Duration::from_secs(5), writer)
3861 .await
3862 .expect("Write did not resume")
3863 .unwrap();
3864 assert!(
3865 matches!(outcome, Err(ToolError::Execution(ref message)) if message.contains("Write committed")),
3866 "committed postverify conflict must stay an error: {outcome:?}"
3867 );
3868 assert_eq!(fs::read_to_string(path).await.unwrap(), "intended");
3869 assert!(
3870 recorder.try_snapshot().unwrap().is_empty(),
3871 "an on-disk mutation is not a successful tool outcome"
3872 );
3873 }
3874
3875 #[tokio::test]
3876 async fn permission_pause_does_not_publish_a_success_event() {
3877 let dir = tempfile::tempdir().unwrap();
3878 let path = dir.path().join("approval-gated.txt");
3879 let config = Arc::new(crate::permission::PermissionConfig::new());
3880 config.set_ask_rules([format!("Write({}/**)", dir.path().display())]);
3881 config.register_session_workspace(
3882 "approval-session",
3883 dir.path().to_string_lossy().into_owned(),
3884 );
3885 let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
3886 let recorder = Arc::new(InMemoryToolEventRecorder::new(4).unwrap());
3887 let executor = BuiltinToolExecutorBuilder::new()
3888 .with_filesystem_tool("Write")
3889 .unwrap()
3890 .with_permission_checker(checker)
3891 .with_tool_event_publisher(recorder.clone())
3892 .build();
3893 let call = make_tool_call_with_id(
3894 "approval-call",
3895 "Write",
3896 json!({"file_path": path, "content": "not-yet"}),
3897 );
3898 let (event_tx, _event_rx) = mpsc::channel(4);
3899 let mut ctx = tool_event_context(
3900 &call,
3901 Some("approval-session"),
3902 Some("approval-root-session"),
3903 );
3904 ctx.event_tx = Some(&event_tx);
3905
3906 let result = executor.execute_with_context(&call, ctx).await.unwrap();
3907 assert!(
3908 result.success,
3909 "approval pause is a synthetic success result"
3910 );
3911 assert_eq!(
3912 result.display_preference.as_deref(),
3913 Some("request_permissions")
3914 );
3915 assert!(!path.exists(), "permission pause must not invoke Write");
3916 assert!(recorder.try_snapshot().unwrap().is_empty());
3917 }
3918
3919 #[tokio::test]
3920 async fn missing_authority_or_oversize_path_fails_closed_without_event() {
3921 let recorder = Arc::new(InMemoryToolEventRecorder::new(4).unwrap());
3922 let executor = marked_stub_write_executor(true, recorder.clone());
3923
3924 let missing_session = make_tool_call_with_id(
3925 "missing-session-call",
3926 "Write",
3927 json!({"file_path": "/bounded/path.txt"}),
3928 );
3929 assert!(
3930 executor
3931 .execute_with_context(
3932 &missing_session,
3933 tool_event_context(&missing_session, None, Some("authority-root-session"),),
3934 )
3935 .await
3936 .unwrap()
3937 .success
3938 );
3939
3940 let missing_root = make_tool_call_with_id(
3941 "missing-root-call",
3942 "Write",
3943 json!({"file_path": "/bounded/path.txt"}),
3944 );
3945 assert!(
3946 executor
3947 .execute_with_context(
3948 &missing_root,
3949 tool_event_context(&missing_root, Some("authority-session"), None),
3950 )
3951 .await
3952 .unwrap()
3953 .success
3954 );
3955
3956 let oversize_path = make_tool_call_with_id(
3957 "oversize-path-call",
3958 "Write",
3959 json!({"file_path": "x".repeat(MAX_TOOL_EVENT_PATH_BYTES + 1)}),
3960 );
3961 assert!(
3962 executor
3963 .execute_with_context(
3964 &oversize_path,
3965 tool_event_context(
3966 &oversize_path,
3967 Some("authority-session"),
3968 Some("authority-root-session"),
3969 ),
3970 )
3971 .await
3972 .unwrap()
3973 .success
3974 );
3975
3976 assert!(recorder.try_snapshot().unwrap().is_empty());
3977 }
3978
3979 #[tokio::test]
3980 async fn custom_write_name_never_acquires_builtin_event_provenance() {
3981 let recorder = Arc::new(InMemoryToolEventRecorder::new(4).unwrap());
3982 let registry = ToolRegistry::new();
3983 registry.register(StubWriteTool { success: true }).unwrap();
3984 let from_registry = BuiltinToolExecutor::with_registry(registry)
3985 .with_tool_event_publisher(recorder.clone());
3986 let first = make_tool_call_with_id(
3987 "spoof-registry-call",
3988 "Write",
3989 json!({"file_path": "/spoof/path.txt"}),
3990 );
3991 assert!(
3992 from_registry
3993 .execute_with_context(
3994 &first,
3995 tool_event_context(&first, Some("spoof-session"), Some("spoof-root-session"),),
3996 )
3997 .await
3998 .unwrap()
3999 .success
4000 );
4001
4002 let custom_before_defaults = BuiltinToolExecutorBuilder::new()
4003 .with_tool(StubWriteTool { success: true })
4004 .unwrap()
4005 .with_default_tools()
4006 .with_tool_event_publisher(recorder.clone())
4007 .build();
4008 let second = make_tool_call_with_id(
4009 "spoof-builder-order-call",
4010 "Write",
4011 json!({"file_path": "/spoof/path.txt"}),
4012 );
4013 assert!(
4014 custom_before_defaults
4015 .execute_with_context(
4016 &second,
4017 tool_event_context(&second, Some("spoof-session"), Some("spoof-root-session"),),
4018 )
4019 .await
4020 .unwrap()
4021 .success
4022 );
4023
4024 let replaced_builtin =
4025 BuiltinToolExecutor::new().with_tool_event_publisher(recorder.clone());
4026 assert!(replaced_builtin.registry().unregister("Write"));
4027 replaced_builtin
4028 .register_tool(StubWriteTool { success: true })
4029 .unwrap();
4030 let third = make_tool_call_with_id(
4031 "spoof-replaced-builtin-call",
4032 "Write",
4033 json!({"file_path": "/spoof/path.txt"}),
4034 );
4035 assert!(
4036 replaced_builtin
4037 .execute_with_context(
4038 &third,
4039 tool_event_context(&third, Some("spoof-session"), Some("spoof-root-session"),),
4040 )
4041 .await
4042 .unwrap()
4043 .success
4044 );
4045
4046 assert!(recorder.try_snapshot().unwrap().is_empty());
4047 }
4048
4049 #[tokio::test]
4050 async fn publisher_rejection_or_panic_never_changes_successful_tool_result() {
4051 let full = Arc::new(InMemoryToolEventRecorder::new(1).unwrap());
4052 full.try_publish(seed_event("seed-full")).unwrap();
4053 assert_real_write_succeeds_with_publisher(full.clone(), "full").await;
4054 let retained = full.try_snapshot().unwrap();
4055 assert_eq!(retained.len(), 1);
4056 assert_eq!(retained[0].context.tool_call_id, "seed-full");
4057
4058 let publishers: Vec<(&str, Arc<dyn ToolEventPublisher>)> = vec![
4059 (
4060 "busy",
4061 Arc::new(ReturningPublisher(ToolEventPublishError::Busy)),
4062 ),
4063 (
4064 "poisoned",
4065 Arc::new(ReturningPublisher(ToolEventPublishError::Poisoned)),
4066 ),
4067 (
4068 "failed",
4069 Arc::new(ReturningPublisher(ToolEventPublishError::Failed(
4070 "sink unavailable".to_string(),
4071 ))),
4072 ),
4073 ("enabled-panic", Arc::new(IsEnabledPanicPublisher)),
4074 ("publish-panic", Arc::new(TryPublishPanicPublisher)),
4075 ];
4076 for (label, publisher) in publishers {
4077 assert_real_write_succeeds_with_publisher(publisher, label).await;
4078 }
4079 }
4080}