1use std::collections::{BTreeMap, BTreeSet};
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, EditTool, ExecCommandTool, ExitPlanModeTool,
17 GetFileInfoTool, GlobTool, GrepTool, KillShellTool, LoopControlTool, ReadTool,
18 RequestPermissionsTool, SessionNoteTool, SleepTool, TaskTool, ToolRegistry, UpdateGoalTool,
19 ViewImageTool, WebFetchTool, WorkspaceTool, WriteStdinTool, 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 parse_warning_log_details<'a>(
43 execution_name: &str,
44 args_raw: &str,
45 warning: &'a str,
46) -> (String, &'a str) {
47 let canonical = canonical_tool_name(execution_name);
48 if ["browser", "browser_eval", "session_message"]
49 .iter()
50 .any(|name| canonical.eq_ignore_ascii_case(name))
51 {
52 ("[redacted]".to_string(), "[redacted]")
55 } else {
56 (preview_for_log(args_raw, 180), warning)
57 }
58}
59
60fn is_browser_download_action(tool_name: &str, args: &serde_json::Value) -> bool {
61 tool_name
62 .trim()
63 .rsplit("::")
64 .next()
65 .is_some_and(|name| name.eq_ignore_ascii_case("browser"))
66 && args.get("action").and_then(serde_json::Value::as_str) == Some("download")
67}
68
69fn approval_tool_name_for_display(tool_name: &str) -> String {
70 if canonical_tool_name(tool_name).eq_ignore_ascii_case("browser") {
71 "browser".to_string()
72 } else {
73 tool_name.to_string()
74 }
75}
76
77fn approval_parameters_for_display(tool_name: &str, args: &serde_json::Value) -> serde_json::Value {
78 if canonical_tool_name(tool_name).eq_ignore_ascii_case("session_message") {
79 return serde_json::json!({"action": args.get("action")});
82 }
83 if canonical_tool_name(tool_name).eq_ignore_ascii_case("browser_eval") {
84 return serde_json::json!({
85 "code":"[redacted]",
86 "expected_url":"[redacted]",
87 "expected_epoch":args.get("expected_epoch").and_then(serde_json::Value::as_u64),
88 });
89 }
90 if is_browser_download_action(tool_name, args) {
91 let mut display = serde_json::json!({"action":"download"});
92 if let Some(epoch) = args
93 .get("expected_epoch")
94 .and_then(serde_json::Value::as_u64)
95 {
96 display["expected_epoch"] = serde_json::json!(epoch);
97 }
98 return display;
99 }
100 if crate::permission::is_private_browser_file_input(tool_name, args) {
101 return serde_json::json!({"action":"set_file_input","file":"[redacted]"});
102 }
103 if crate::permission::is_native_browser_select(tool_name, args) {
104 return serde_json::json!({"action":"select_option"});
107 }
108 let mut display = args.clone();
109 if let Some(parameters) = display.as_object_mut() {
110 if crate::permission::is_focused_browser_input(tool_name, args) {
111 parameters.remove("text");
112 parameters.remove("key");
113 }
114 }
115 display
116}
117
118fn copy_legacy_arg_if_missing(
119 args: &mut serde_json::Map<String, serde_json::Value>,
120 from: &str,
121 to: &str,
122) {
123 if args.contains_key(to) {
124 return;
125 }
126 if let Some(value) = args.get(from).cloned() {
127 args.insert(to.to_string(), value);
128 }
129}
130
131fn normalize_legacy_builtin_args(
132 raw_tool_name: &str,
133 args: &mut serde_json::Map<String, serde_json::Value>,
134) {
135 match raw_tool_name {
136 "read_file" | "write_file" | "Read" | "Write" | "apply_patch" => {
137 copy_legacy_arg_if_missing(args, "path", "file_path");
138 }
139 "execute_command" | "Bash" => {
140 copy_legacy_arg_if_missing(args, "cmd", "command");
141 }
142 "list_directory" | "Glob" => {
143 let should_default_pattern = raw_tool_name == "list_directory"
144 || args.contains_key("path")
145 || args.contains_key("recursive");
146 if should_default_pattern && !args.contains_key("pattern") {
147 let recursive = args
148 .get("recursive")
149 .and_then(serde_json::Value::as_bool)
150 .unwrap_or(false);
151 let pattern = if recursive { "**/*" } else { "*" };
152 args.insert(
153 "pattern".to_string(),
154 serde_json::Value::String(pattern.to_string()),
155 );
156 }
157 args.remove("recursive");
158 }
159 _ => {}
160 }
161}
162
163fn resolve_registered_tool_name(registry: &ToolRegistry, reference: &str) -> Option<String> {
164 resolve_tool_reference_name(reference, |candidate| registry.contains(candidate))
165}
166
167fn normalize_resolved_builtin_args(
172 reference: &str,
173 execution_name: &str,
174 args: &mut serde_json::Value,
175) {
176 if !matches!(execution_name, "Read" | "Write" | "Edit" | "Bash" | "Glob") {
177 return;
178 }
179 let unqualified = reference
180 .trim()
181 .rsplit("::")
182 .next()
183 .unwrap_or(reference)
184 .trim();
185 if let Some(args_obj) = args.as_object_mut() {
186 normalize_legacy_builtin_args(unqualified, args_obj);
187 }
188}
189
190pub struct BuiltinToolExecutor {
192 registry: ToolRegistry,
193 native_tool_ceiling: Option<BTreeSet<String>>,
194 permission_checker: Option<Arc<dyn PermissionChecker>>,
195 framework_builtin_tools: BTreeMap<String, Arc<dyn Tool>>,
199 tool_event_publisher: Arc<dyn ToolEventPublisher>,
200}
201
202impl BuiltinToolExecutor {
203 fn default_tool_event_publisher() -> Arc<dyn ToolEventPublisher> {
204 Arc::new(NoopToolEventPublisher)
205 }
206
207 pub fn new() -> Self {
209 let registry = ToolRegistry::new();
210 let framework_builtin_tools = Self::register_builtin_tools(®istry);
211 Self {
212 registry,
213 permission_checker: None,
214 framework_builtin_tools,
215 native_tool_ceiling: None,
216 tool_event_publisher: Self::default_tool_event_publisher(),
217 }
218 }
219
220 pub fn new_with_permissions(permission_checker: Arc<dyn PermissionChecker>) -> Self {
222 let registry = ToolRegistry::new();
223 let framework_builtin_tools = Self::register_builtin_tools(®istry);
224 Self {
225 registry,
226 permission_checker: Some(permission_checker),
227 framework_builtin_tools,
228 native_tool_ceiling: None,
229 tool_event_publisher: Self::default_tool_event_publisher(),
230 }
231 }
232
233 pub fn new_with_config(_config: Arc<RwLock<Config>>) -> Self {
236 Self::new()
237 }
238
239 pub fn new_with_config_and_permissions(
241 _config: Arc<RwLock<Config>>,
242 permission_checker: Arc<dyn PermissionChecker>,
243 ) -> Self {
244 Self::new_with_permissions(permission_checker)
245 }
246
247 pub fn with_registry(registry: ToolRegistry) -> Self {
249 Self {
250 registry,
251 permission_checker: None,
252 framework_builtin_tools: BTreeMap::new(),
253 native_tool_ceiling: None,
254 tool_event_publisher: Self::default_tool_event_publisher(),
255 }
256 }
257
258 pub fn with_registry_and_permissions(
265 registry: ToolRegistry,
266 permission_checker: Arc<dyn PermissionChecker>,
267 ) -> Self {
268 Self {
269 registry,
270 permission_checker: Some(permission_checker),
271 framework_builtin_tools: BTreeMap::new(),
272 native_tool_ceiling: None,
273 tool_event_publisher: Self::default_tool_event_publisher(),
274 }
275 }
276
277 pub fn with_tool_event_publisher(mut self, publisher: Arc<dyn ToolEventPublisher>) -> Self {
279 self.tool_event_publisher = publisher;
280 self
281 }
282
283 pub fn registry(&self) -> &ToolRegistry {
285 &self.registry
286 }
287
288 fn pending_file_changed(
289 &self,
290 tool_name: &str,
291 tool: &Arc<dyn Tool>,
292 args: &serde_json::Value,
293 ) -> Option<FileChangedV1> {
294 let builtin = self.framework_builtin_tools.get(tool_name)?;
295 if !Arc::ptr_eq(builtin, tool) {
296 return None;
297 }
298 if !matches!(tool_name, "Write" | "Edit") {
299 return None;
300 }
301 let path = args.get("file_path")?.as_str()?.trim();
302 FileChangedV1::bounded_from(path).ok()
303 }
304
305 fn publish_successful_file_change(
306 &self,
307 ctx: &ToolExecutionContext<'_>,
308 tool_name: &str,
309 data: FileChangedV1,
310 ) {
311 let Some(session_id) = ctx.session_id else {
312 return;
313 };
314 let Some(root_session_id) = ctx.root_session_id else {
315 return;
316 };
317 let Ok(context) = ToolEventContextV1::bounded_from(
318 session_id,
319 root_session_id,
320 tool_name,
321 ctx.tool_call_id,
322 ) else {
323 return;
324 };
325 let Ok(event) = ToolEventV1::file_changed(context, data) else {
326 return;
327 };
328
329 let publisher = self.tool_event_publisher.as_ref();
332 let _ = catch_unwind(AssertUnwindSafe(|| publisher.try_publish(event)));
333 }
334
335 fn register_builtin_tools(registry: &ToolRegistry) -> BTreeMap<String, Arc<dyn Tool>> {
337 let mut framework_tools = BTreeMap::new();
338 if let Ok((name, tool)) = Self::register_tracked_builtin(registry, BashTool::new()) {
340 framework_tools.insert(name, tool);
341 }
342 if let Ok((name, tool)) = Self::register_tracked_builtin(registry, ExecCommandTool::new()) {
343 framework_tools.insert(name, tool);
344 }
345 if let Ok((name, tool)) = Self::register_tracked_builtin(registry, WriteStdinTool::new()) {
346 framework_tools.insert(name, tool);
347 }
348 let _ = registry.register(BashInputTool::new());
349 let _ = registry.register(BashOutputTool::new());
350 if let Ok((name, tool)) = Self::register_tracked_builtin(registry, EditTool::new()) {
351 framework_tools.insert(name, tool);
352 }
353 let _ = registry.register(ExitPlanModeTool::new());
354 let _ = registry.register(GetFileInfoTool::new());
356 if let Ok((name, tool)) = Self::register_tracked_builtin(registry, GlobTool::new()) {
357 framework_tools.insert(name, tool);
358 }
359 let _ = registry.register(GrepTool::new());
360 let _ = registry.register(UpdateGoalTool::new());
361 let _ = registry.register(KillShellTool::new());
362 let _ = registry.register(LoopControlTool::new());
363 let _ = registry.register(SessionNoteTool::new());
364 if let Ok((name, tool)) = Self::register_tracked_builtin(registry, ReadTool::new()) {
365 framework_tools.insert(name, tool);
366 }
367 let _ = registry.register(RequestPermissionsTool::new());
368 let _ = registry.register(SleepTool::new());
369 if let Ok((name, tool)) = Self::register_tracked_builtin(registry, TaskTool::new()) {
370 framework_tools.insert(name, tool);
371 }
372 let _ = registry.register(ViewImageTool::new());
373 let _ = registry.register(WebFetchTool::new());
374 let _ = registry.register(WorkspaceTool::new());
376 if let Ok((name, tool)) = Self::register_tracked_builtin(registry, WriteTool::new()) {
377 framework_tools.insert(name, tool);
378 }
379 framework_tools
380 }
381
382 fn register_tracked_builtin<T: Tool + 'static>(
383 registry: &ToolRegistry,
384 tool: T,
385 ) -> Result<(String, Arc<dyn Tool>), ToolError> {
386 let name = tool.name().to_string();
387 let tool: Arc<dyn Tool> = Arc::new(tool);
388 registry
389 .register_shared(tool.clone())
390 .map_err(|error| ToolError::Execution(error.to_string()))?;
391 Ok((name, tool))
392 }
393
394 fn is_framework_builtin_instance(&self, execution_name: &str, tool: &Arc<dyn Tool>) -> bool {
395 self.framework_builtin_tools
396 .get(execution_name)
397 .is_some_and(|builtin| Arc::ptr_eq(builtin, tool))
398 }
399
400 const NATIVE_TOOL_NAMES: [&'static str; 8] = [
403 "Bash",
404 "Edit",
405 "Glob",
406 "Read",
407 "Task",
408 "Write",
409 "exec_command",
410 "write_stdin",
411 ];
412
413 pub fn eligible_native_tool(&self, name: &str) -> bool {
415 Self::NATIVE_TOOL_NAMES.contains(&name)
416 && self.registry.get(name).is_some_and(|tool| {
417 self.is_framework_builtin_instance(name, &tool)
418 && self
419 .native_tool_ceiling
420 .as_ref()
421 .is_none_or(|set| set.contains(name))
422 })
423 }
424
425 pub fn with_native_tool_ceiling(mut self, names: Vec<String>) -> Result<Self, ToolError> {
427 let set: BTreeSet<_> = names.iter().cloned().collect();
428 if set.len() != names.len()
429 || names.len() > Self::NATIVE_TOOL_NAMES.len()
430 || names.iter().any(|name| !self.eligible_native_tool(name))
431 {
432 return Err(ToolError::Execution("native_tool_ceiling_invalid".into()));
433 }
434 self.native_tool_ceiling = Some(set);
435 Ok(self)
436 }
437
438 fn check_native_ceiling(&self, name: &str, tool: &Arc<dyn Tool>) -> Result<(), ToolError> {
439 if self.native_tool_ceiling.as_ref().is_some_and(|set| {
440 !set.contains(name) || !self.is_framework_builtin_instance(name, tool)
441 }) {
442 return Err(ToolError::Execution("native_tool_ceiling_denied".into()));
443 }
444 Ok(())
445 }
446
447 fn normalize_registered_builtin_args(
448 &self,
449 reference: &str,
450 execution_name: &str,
451 tool: &Arc<dyn Tool>,
452 args: &mut serde_json::Value,
453 ) {
454 if self.is_framework_builtin_instance(execution_name, tool) {
455 normalize_resolved_builtin_args(reference, execution_name, args);
456 }
457 }
458
459 pub fn tool_schemas() -> Vec<ToolSchema> {
461 let registry = ToolRegistry::new();
462 let _ = Self::register_builtin_tools(®istry);
463 registry.list_tools()
464 }
465
466 pub fn register_tool<T: Tool + 'static>(&self, tool: T) -> Result<(), ToolError> {
468 self.registry
469 .register(tool)
470 .map_err(|e| ToolError::Execution(e.to_string()))
471 }
472
473 pub fn register_tool_with_guide<T, G>(&self, tool: T, guide: G) -> Result<(), ToolError>
475 where
476 T: Tool + 'static,
477 G: ToolGuide + 'static,
478 {
479 self.registry
480 .register_with_guide(tool, guide)
481 .map_err(|e| ToolError::Execution(e.to_string()))
482 }
483
484 pub fn get_guide(&self, tool_name: &str) -> Option<Arc<dyn ToolGuide>> {
486 if self.native_tool_ceiling.is_some() && !self.eligible_native_tool(tool_name) {
487 return None;
488 }
489 self.registry.get_guide(tool_name)
490 }
491
492 fn parse_execution_args(
493 &self,
494 call: &ToolCall,
495 execution_name: &str,
496 ctx: &ToolExecutionContext<'_>,
497 ) -> serde_json::Value {
498 if let Some(pre_parsed) = ctx.pre_parsed_args {
499 return pre_parsed.clone();
500 }
501 let args_raw = call.function.arguments.trim();
502 let (parsed, parse_warning) = parse_tool_args_best_effort(&call.function.arguments);
503 if let Some(warning) = parse_warning {
504 let (args_preview, warning) =
505 parse_warning_log_details(execution_name, args_raw, &warning);
506 tracing::warn!(
507 "Builtin tool argument parsing fallback applied: session_id={:?}, tool_call_id={}, tool_name={}, args_len={}, args_preview=\"{}\", warning={}",
508 ctx.session_id,
509 call.id,
510 call.function.name,
511 args_raw.len(),
512 args_preview,
513 warning
514 );
515 }
516 parsed
517 }
518
519 async fn execute_registered_with_context_outcome(
520 &self,
521 call: &ToolCall,
522 execution_name: &str,
523 ctx: ToolExecutionContext<'_>,
524 ) -> Result<ToolOutcome, ToolError> {
525 let tool = self
526 .registry
527 .get(execution_name)
528 .ok_or_else(|| ToolError::NotFound(format!("Tool '{}' not found", execution_name)))?;
529 self.check_native_ceiling(execution_name, &tool)?;
530 check_raw_tool_input(execution_name, &call.function.arguments)?;
531 let mut args = self.parse_execution_args(call, execution_name, &ctx);
532 check_parsed_tool_input(execution_name, &args)?;
533 self.normalize_registered_builtin_args(
534 &call.function.name,
535 execution_name,
536 &tool,
537 &mut args,
538 );
539
540 if let Some(outcome) = self
541 .check_permissions_for_resolved(call, execution_name, &args, &ctx)
542 .await?
543 {
544 return Ok(outcome);
545 }
546
547 let publisher_enabled =
548 catch_unwind(AssertUnwindSafe(|| self.tool_event_publisher.is_enabled()))
549 .unwrap_or(false);
550 let pending_file_changed = publisher_enabled
551 .then(|| self.pending_file_changed(execution_name, &tool, &args))
552 .flatten();
553
554 let owned_resources = crate::LocalTaskResources::current().filter(|_| {
559 matches!(execution_name, "Write" | "Edit" | "Bash" | "exec_command")
560 && self.is_framework_builtin_instance(execution_name, &tool)
561 });
562 let outcome = if let Some(resources) = owned_resources {
563 let tool_ctx = ctx.to_tool_ctx();
564 let output_cap = bamboo_agent_core::tools::observed_tool_output_cap(&tool_ctx);
565 let approval = crate::current_approval_proxy();
566 let permission_override = crate::current_hook_permission_override(&call.id);
567 let root_writer = bamboo_agent_core::tools::context::root_actor_tool_writer();
568 let call_id = call.id.clone();
569 let session_id = ctx.session_id.unwrap_or_default().to_owned();
570 let suspension = bamboo_agent_core::tools::capture_suspension_restriction();
571 let invoke = async move {
572 let invoke = async move {
573 let future = tool.invoke(args, tool_ctx);
574 let future = crate::with_approval_proxy(approval, future);
575 let future =
576 crate::with_hook_permission_override(permission_override, &call_id, future);
577 let future = bamboo_agent_core::tools::scope_tool_output_cap(
578 &session_id,
579 &call_id,
580 output_cap,
581 future,
582 );
583 let result = match root_writer {
585 Some(writer) => {
586 bamboo_agent_core::tools::context::with_root_actor_tool_writer(
587 writer, future,
588 )
589 .await
590 }
591 None => future.await,
592 };
593 enforce_registered_tool_suspension(result)
594 };
595 match suspension {
598 Some(scope) => scope.scope(invoke).await,
599 None => invoke.await,
600 }
601 };
602 let registered = resources
603 .spawn_owned(invoke)
604 .map_err(|error| ToolError::Execution(error.to_string()))?;
605 crate::local_task_resources::mark_current_invocation_owned();
606 registered.await.map_err(|_| {
607 ToolError::Execution(
608 "owned tool invocation panicked; stop remains unconfirmed".into(),
609 )
610 })?
611 } else {
612 tool.invoke(args, ctx.to_tool_ctx()).await
613 };
614 let outcome = enforce_registered_tool_suspension(outcome)?;
617 if matches!(
618 &outcome,
619 ToolOutcome::Completed(result) if result.success
620 ) {
621 if let Some(data) = pending_file_changed {
622 self.publish_successful_file_change(&ctx, execution_name, data);
623 }
624 }
625 Ok(outcome)
626 }
627
628 pub fn build_enhanced_prompt(&self, context: GuideBuildContext) -> String {
630 EnhancedPromptBuilder::build(Some(&self.registry), &self.list_tools(), &context)
631 }
632}
633
634fn enforce_registered_tool_suspension(
637 outcome: Result<ToolOutcome, ToolError>,
638) -> Result<ToolOutcome, ToolError> {
639 let outcome = outcome.map(ToolOutcome::enforce_suspension_restriction);
640 if bamboo_agent_core::tools::suspension_running_violated() {
641 if let Some(resources) = crate::LocalTaskResources::current() {
642 resources.record_unconfirmed(
643 "restricted tool returned Running; cooperative kill has no stop receipt",
644 );
645 }
646 }
647 outcome
648}
649
650fn permission_error_to_tool_error(error: PermissionError) -> ToolError {
651 match error {
652 PermissionError::CheckFailed(_) => ToolError::InvalidArguments(error.to_string()),
653 _ => ToolError::Execution(error.to_string()),
654 }
655}
656
657impl Default for BuiltinToolExecutor {
658 fn default() -> Self {
659 Self::new()
660 }
661}
662
663#[async_trait]
664impl ToolExecutor for BuiltinToolExecutor {
665 async fn execute(&self, call: &ToolCall) -> Result<ToolResult, ToolError> {
666 self.execute_with_context(call, ToolExecutionContext::none(&call.id))
667 .await
668 }
669
670 async fn execute_with_context(
671 &self,
672 call: &ToolCall,
673 ctx: ToolExecutionContext<'_>,
674 ) -> Result<ToolResult, ToolError> {
675 self.execute_with_context_outcome(call, ctx)
676 .await
677 .map(ToolOutcome::into_tool_result)
678 }
679
680 async fn execute_with_context_outcome(
681 &self,
682 call: &ToolCall,
683 ctx: ToolExecutionContext<'_>,
684 ) -> Result<ToolOutcome, ToolError> {
685 let reference = call.function.name.trim();
686 let tool_name =
687 resolve_registered_tool_name(&self.registry, reference).ok_or_else(|| {
688 ToolError::NotFound(format!("Tool '{}' not found", call.function.name))
689 })?;
690 self.execute_registered_with_context_outcome(call, &tool_name, ctx)
691 .await
692 }
693
694 async fn execute_exact_with_context_outcome(
695 &self,
696 call: &ToolCall,
697 execution_name: &str,
698 ctx: ToolExecutionContext<'_>,
699 ) -> Result<ToolOutcome, ToolError> {
700 self.execute_registered_with_context_outcome(call, execution_name, ctx)
701 .await
702 }
703
704 async fn check_permissions_for(
728 &self,
729 call: &ToolCall,
730 ctx: &ToolExecutionContext<'_>,
731 ) -> Result<Option<ToolOutcome>, ToolError> {
732 let reference = call.function.name.trim();
733 let tool_name = resolve_registered_tool_name(&self.registry, reference)
734 .unwrap_or_else(|| canonical_tool_name(reference));
735 let mut args = if let Some(pre_parsed) = ctx.pre_parsed_args {
736 pre_parsed.clone()
737 } else {
738 parse_tool_args_best_effort(&call.function.arguments).0
739 };
740 if let Some(tool) = self.registry.get(&tool_name) {
741 self.normalize_registered_builtin_args(reference, &tool_name, &tool, &mut args);
742 }
743 self.check_permissions_for_resolved(call, &tool_name, &args, ctx)
744 .await
745 }
746
747 async fn check_permissions_for_exact(
748 &self,
749 call: &ToolCall,
750 execution_name: &str,
751 ctx: &ToolExecutionContext<'_>,
752 ) -> Result<Option<ToolOutcome>, ToolError> {
753 let tool = self
754 .registry
755 .get(execution_name)
756 .ok_or_else(|| ToolError::NotFound(format!("Tool '{}' not found", execution_name)))?;
757 let mut args = if let Some(pre_parsed) = ctx.pre_parsed_args {
758 pre_parsed.clone()
759 } else {
760 parse_tool_args_best_effort(&call.function.arguments).0
761 };
762 self.normalize_registered_builtin_args(
763 call.function.name.trim(),
764 execution_name,
765 &tool,
766 &mut args,
767 );
768 self.check_permissions_for_resolved(call, execution_name, &args, ctx)
769 .await
770 }
771
772 async fn check_permissions_for_resolved(
773 &self,
774 call: &ToolCall,
775 execution_name: &str,
776 resolved_args: &serde_json::Value,
777 ctx: &ToolExecutionContext<'_>,
778 ) -> Result<Option<ToolOutcome>, ToolError> {
779 if self.native_tool_ceiling.is_some() {
780 let tool = self
781 .registry
782 .get(execution_name)
783 .ok_or_else(|| ToolError::Execution("native_tool_ceiling_denied".into()))?;
784 self.check_native_ceiling(execution_name, &tool)?;
785 }
786 let tool_name = execution_name.to_string();
787 let args = resolved_args.clone();
788 if ctx.auto_approve_permissions && tool_name.eq_ignore_ascii_case("request_permissions") {
789 return Err(ToolError::Execution(
790 "Auto mode cannot request expanded permissions; operate within existing hard boundaries"
791 .to_string(),
792 ));
793 }
794 if ctx.plan_read_only
795 && (!crate::orchestrator::plan_mode_allows_tool(&tool_name)
796 || (tool_name == "session_message"
797 && args.get("action").and_then(serde_json::Value::as_str)
798 != Some("list_targets")))
799 {
800 return Err(ToolError::Execution(format!(
801 "Plan mode: {tool_name} operation blocked"
802 )));
803 }
804 let Some(permission_checker) = &self.permission_checker else {
805 return Ok(None);
806 };
807 let hook_permission_override = crate::current_hook_permission_override(&call.id);
808
809 if let Some(contexts) =
810 check_permissions(&tool_name, &args).map_err(permission_error_to_tool_error)?
811 {
812 let proactive_permission_request =
813 tool_name.eq_ignore_ascii_case("request_permissions");
814 for context in contexts {
815 let resource = context.resource.clone();
816 let operation_summary = context.operation_description.clone();
817 let risk_level = context.risk_level();
818 let permission_type = context.permission_type;
819 let focused_browser_input =
820 crate::permission::is_focused_browser_input(&tool_name, &args);
821 let native_browser_select =
822 crate::permission::is_native_browser_select(&tool_name, &args);
823 let browser_eval =
824 canonical_tool_name(&tool_name).eq_ignore_ascii_case("browser_eval");
825 let browser_download = is_browser_download_action(&tool_name, &args);
826 let private_browser_file_input = !browser_download
827 && crate::permission::is_private_browser_file_input(&tool_name, &args);
828 let private_browser_display = focused_browser_input
829 || native_browser_select
830 || browser_eval
831 || browser_download
832 || private_browser_file_input;
833 let denied_message = if browser_eval {
834 "Browser page script denied by policy"
835 } else if private_browser_file_input {
836 "Browser file input denied by policy"
837 } else if native_browser_select {
838 "Browser selection denied by policy"
839 } else if browser_download {
840 "Browser download denied by policy"
841 } else {
842 "Browser input denied by policy"
843 };
844 let check_failed_message = if browser_eval {
845 "Browser page script permission check failed"
846 } else if private_browser_file_input {
847 "Browser file input permission check failed"
848 } else if native_browser_select {
849 "Browser selection permission check failed"
850 } else if browser_download {
851 "Browser download permission check failed"
852 } else {
853 "Browser input permission check failed"
854 };
855 let approval_display_resource = if browser_eval {
856 "Execute browser page JavaScript".to_string()
857 } else if private_browser_display {
858 operation_summary.clone()
859 } else {
860 resource.clone()
861 };
862 let platform_hard_deny = permission_checker.hard_deny_reason(&context);
863 let config = permission_checker.permission_config();
864 let proxy = crate::approval::current_approval_proxy();
865 let request = if let Some(config) = config.as_ref() {
866 if proactive_permission_request && proxy.is_some() {
867 return Err(ToolError::Execution(
868 "request_permissions requires the local typed decision protocol; a boolean approval relay cannot create remembered authority"
869 .to_string(),
870 ));
871 }
872 let mut supported_decisions = if proxy.is_some() {
876 crate::permission::PermissionRequest::forced_decisions()
877 } else {
878 crate::permission::PermissionRequest::ordinary_decisions(true)
879 };
880 if proactive_permission_request {
881 supported_decisions.retain(|decision| {
886 *decision != crate::permission::PermissionDecisionKind::AllowOnce
887 });
888 }
889 let workspace_path = ctx
894 .session_id
895 .and_then(|session_id| config.session_workspace(session_id));
896 match config.evaluate(crate::permission::PermissionEvaluation {
897 request_id: call.id.clone(),
898 session_id: ctx.session_id.unwrap_or_default().to_string(),
899 workspace_path,
900 tool_name: tool_name.clone(),
901 tool_args: args.clone(),
902 permission_type,
903 resource: resource.clone(),
904 operation_summary: operation_summary.clone(),
905 risk_level,
906 bypass_requested: ctx.bypass_permissions,
907 auto_approve_requested: ctx.auto_approve_permissions,
908 platform_hard_deny,
909 consume_once: true,
910 supported_decisions,
911 }) {
912 crate::permission::PermissionOutcome::Allow { .. } => continue,
913 crate::permission::PermissionOutcome::Deny { reason, .. } => {
914 return Err(ToolError::Execution(if private_browser_display {
915 denied_message.to_string()
916 } else {
917 reason.message
918 }));
919 }
920 crate::permission::PermissionOutcome::Ask(request)
921 if matches!(
922 hook_permission_override,
923 Some(crate::HookPermissionOverride::Allow)
924 ) && !proactive_permission_request
925 && request.reason_code
926 != crate::permission::PermissionReasonCode::HardDangerous =>
927 {
928 continue;
929 }
930 crate::permission::PermissionOutcome::Ask(request) => request,
931 }
932 } else {
933 if proactive_permission_request {
934 return Err(ToolError::Execution(
935 "request_permissions requires a typed PermissionConfig and cannot fall back to a display-string approval"
936 .to_string(),
937 ));
938 }
939 if let Some(reason) = platform_hard_deny {
942 return Err(ToolError::Execution(if private_browser_display {
943 denied_message.to_string()
944 } else {
945 reason
946 }));
947 }
948 let force_ask = focused_browser_input
949 || permission_checker.requires_forced_confirmation(&tool_name, &args);
950 let hook_allows = matches!(
951 hook_permission_override,
952 Some(crate::HookPermissionOverride::Allow)
953 );
954 if ctx.auto_approve_permissions
955 || ((ctx.bypass_permissions || hook_allows) && !force_ask)
956 {
957 continue;
958 }
959 if !bamboo_agent_core::tools::suspension_allowed() {
960 return Err(ToolError::Execution(
964 "suspension_disabled: permission approval is unavailable in this execution".into(),
965 ));
966 }
967 let decision = if focused_browser_input {
968 permission_checker.request_confirmation(context).await
972 } else if force_ask {
973 permission_checker.check_or_request_forced(context).await
974 } else if let Some(session_id) = ctx.session_id {
975 permission_checker
976 .check_or_request_for_session(session_id, context)
977 .await
978 } else {
979 permission_checker.check_or_request(context).await
980 };
981 match decision {
982 Ok(true) => continue,
983 Ok(false) => {
984 return Err(ToolError::Execution(format!(
985 "Permission denied for: {}",
986 approval_display_resource
987 )));
988 }
989 Err(PermissionError::ConfirmationRequired { .. }) => {
990 crate::permission::PermissionRequest {
991 request_id: call.id.clone(),
992 request_generation:
993 crate::permission::PermissionRequest::fresh_generation(),
994 session_id: ctx.session_id.unwrap_or_default().to_string(),
995 workspace_path: None,
996 tool_name: tool_name.clone(),
997 permission_type,
998 resource: resource.clone(),
999 operation_summary: operation_summary.clone(),
1000 risk_level,
1001 reason_code: if force_ask {
1002 crate::permission::PermissionReasonCode::ConfiguredAlwaysAsk
1003 } else {
1004 crate::permission::PermissionReasonCode::RiskThreshold
1005 },
1006 effective_mode: bamboo_config::settings::PermissionMode::Default,
1007 bypass_requested: ctx.bypass_permissions,
1008 auto_approve_requested: ctx.auto_approve_permissions,
1009 policy_revision: 0,
1010 matched_rule: None,
1011 allowed_decisions:
1012 crate::permission::PermissionRequest::forced_decisions(),
1013 suggested_matchers: if focused_browser_input {
1014 Vec::new()
1015 } else {
1016 crate::permission::conservative_matchers(
1017 permission_type,
1018 &resource,
1019 )
1020 },
1021 }
1022 }
1023 Err(other) => {
1024 return Err(if private_browser_display {
1025 ToolError::Execution(check_failed_message.to_string())
1026 } else {
1027 permission_error_to_tool_error(other)
1028 });
1029 }
1030 }
1031 };
1032
1033 if !bamboo_agent_core::tools::suspension_allowed() {
1034 return Err(ToolError::Execution(
1035 "suspension_disabled: permission approval is unavailable in this execution"
1036 .into(),
1037 ));
1038 }
1039
1040 if let Some(proxy) = proxy {
1044 let mut display_request = request.clone();
1045 if canonical_tool_name(&tool_name).eq_ignore_ascii_case("browser") {
1046 display_request.tool_name = "browser".to_string();
1047 }
1048 if private_browser_display {
1049 display_request.resource = "[redacted]".to_string();
1050 display_request.matched_rule = None;
1051 display_request.suggested_matchers.clear();
1052 if browser_eval {
1053 display_request.operation_summary =
1054 "Execute browser page JavaScript".to_string();
1055 }
1056 }
1057 let approved = proxy
1058 .request_approval(crate::approval::ApprovalAsk {
1059 tool_name: approval_tool_name_for_display(&tool_name),
1060 permission: permission_type.description().to_string(),
1061 resource: approval_display_resource.clone(),
1062 permission_request: Some(display_request),
1063 })
1064 .await;
1065 if approved {
1066 continue;
1067 }
1068 return Err(ToolError::Execution(format!(
1069 "Permission denied by host for: {}",
1070 approval_display_resource
1071 )));
1072 }
1073
1074 if let Some(tx) = ctx.event_tx {
1077 let approval_parameters = approval_parameters_for_display(&tool_name, &args);
1078 let _ = tx
1079 .send(bamboo_agent_core::AgentEvent::ToolApprovalRequested {
1080 tool_call_id: call.id.clone(),
1081 tool_name: approval_tool_name_for_display(&tool_name),
1082 parameters: approval_parameters,
1083 })
1084 .await;
1085
1086 let question = format!(
1087 "**Permission required**\n\nThe `{}` tool needs approval to {} on:\n\n`{}`",
1088 approval_tool_name_for_display(&tool_name),
1089 permission_type.description(),
1090 approval_display_resource
1091 );
1092 if let Some(config) = config {
1093 config.register_pending_request(request.clone());
1094 }
1095 let payload = serde_json::json!({
1096 "status": "awaiting_permission_approval",
1097 "question": question,
1098 "permission_type": permission_type,
1099 "resource": approval_display_resource,
1100 "options": ["Approve", "Deny"],
1101 "allow_custom": false,
1102 "permission_request": request,
1103 });
1104 return Ok(Some(ToolOutcome::Completed(ToolResult {
1105 success: true,
1106 result: payload.to_string(),
1107 display_preference: Some("request_permissions".to_string()),
1108 images: Vec::new(),
1109 })));
1110 }
1111
1112 return Err(ToolError::Execution(format!(
1113 "Permission approval required for: {}",
1114 approval_display_resource
1115 )));
1116 }
1117 }
1118
1119 Ok(None)
1120 }
1121
1122 fn list_tools(&self) -> Vec<ToolSchema> {
1123 self.registry
1124 .list_tools()
1125 .into_iter()
1126 .filter(|schema| {
1127 self.native_tool_ceiling.is_none()
1128 || self.eligible_native_tool(&schema.function.name)
1129 })
1130 .collect()
1131 }
1132
1133 fn owns_exact_tool(&self, tool_name: &str) -> bool {
1134 self.registry.contains(tool_name)
1135 && (self.native_tool_ceiling.is_none() || self.eligible_native_tool(tool_name))
1136 }
1137
1138 fn exact_tool_owner(&self, name: &str) -> Option<&dyn ToolExecutor> {
1139 self.owns_exact_tool(name)
1140 .then_some(self as &dyn ToolExecutor)
1141 }
1142
1143 fn tool_mutability(&self, tool_name: &str) -> crate::ToolMutability {
1144 let resolved = resolve_registered_tool_name(&self.registry, tool_name);
1145 if self.native_tool_ceiling.is_some()
1146 && resolved
1147 .as_deref()
1148 .is_none_or(|name| !self.eligible_native_tool(name))
1149 {
1150 return crate::ToolMutability::Mutating;
1151 }
1152 resolved
1153 .as_deref()
1154 .and_then(|name| self.registry.get(name))
1155 .map(|tool| tool.classify(&serde_json::Value::Null).mutability)
1156 .unwrap_or_else(|| crate::classify_tool(&canonical_tool_name(tool_name)))
1157 }
1158
1159 fn call_mutability(&self, call: &ToolCall) -> crate::ToolMutability {
1160 self.call_parallel_classification(call).0
1161 }
1162
1163 fn tool_concurrency_safe(&self, tool_name: &str) -> bool {
1164 let resolved = resolve_registered_tool_name(&self.registry, tool_name);
1165 if self.native_tool_ceiling.is_some()
1166 && resolved
1167 .as_deref()
1168 .is_none_or(|name| !self.eligible_native_tool(name))
1169 {
1170 return false;
1171 }
1172 resolved
1173 .as_deref()
1174 .and_then(|name| self.registry.get(name))
1175 .map(|tool| tool.classify(&serde_json::Value::Null).parallel_safe)
1176 .unwrap_or_else(|| self.tool_mutability(tool_name) == crate::ToolMutability::ReadOnly)
1177 }
1178
1179 fn call_concurrency_safe(&self, call: &ToolCall) -> bool {
1180 self.call_parallel_classification(call).1
1181 }
1182
1183 fn call_parallel_classification(&self, call: &ToolCall) -> (crate::ToolMutability, bool) {
1184 let reference = call.function.name.trim();
1188 let resolved = resolve_registered_tool_name(&self.registry, reference);
1189 let mut args = bamboo_agent_core::parse_tool_args_best_effort(&call.function.arguments).0;
1190 match resolved.as_deref().and_then(|execution_name| {
1191 self.registry
1192 .get(execution_name)
1193 .map(|tool| (execution_name, tool))
1194 }) {
1195 Some((execution_name, tool)) => {
1196 if self.check_native_ceiling(execution_name, &tool).is_err() {
1197 return (crate::ToolMutability::Mutating, false);
1198 }
1199 self.normalize_registered_builtin_args(reference, execution_name, &tool, &mut args);
1200 let class = tool.classify(&args);
1201 (class.mutability, class.parallel_safe)
1202 }
1203 None => (
1204 self.tool_mutability(reference),
1205 self.tool_concurrency_safe(reference),
1206 ),
1207 }
1208 }
1209}
1210
1211pub struct BuiltinToolExecutorBuilder {
1213 registry: ToolRegistry,
1214 permission_checker: Option<Arc<dyn PermissionChecker>>,
1215 framework_builtin_tools: BTreeMap<String, Arc<dyn Tool>>,
1216 tool_event_publisher: Arc<dyn ToolEventPublisher>,
1217}
1218
1219impl BuiltinToolExecutorBuilder {
1220 pub fn new() -> Self {
1222 Self {
1223 registry: ToolRegistry::new(),
1224 permission_checker: None,
1225 framework_builtin_tools: BTreeMap::new(),
1226 tool_event_publisher: BuiltinToolExecutor::default_tool_event_publisher(),
1227 }
1228 }
1229
1230 pub fn with_default_tools(mut self) -> Self {
1232 self.framework_builtin_tools
1233 .extend(BuiltinToolExecutor::register_builtin_tools(&self.registry));
1234 self
1235 }
1236
1237 pub fn with_filesystem_tool(mut self, name: &str) -> Result<Self, ToolError> {
1239 if name == "ViewImage" {
1240 self.registry
1241 .register(ViewImageTool::new())
1242 .map_err(|error| ToolError::Execution(error.to_string()))?;
1243 return Ok(self);
1244 }
1245 let (name, tool) = match name {
1246 "Read" => {
1247 BuiltinToolExecutor::register_tracked_builtin(&self.registry, ReadTool::new())?
1248 }
1249 "Write" => {
1250 BuiltinToolExecutor::register_tracked_builtin(&self.registry, WriteTool::new())?
1251 }
1252 "Edit" | "apply_patch" => {
1254 BuiltinToolExecutor::register_tracked_builtin(&self.registry, EditTool::new())?
1255 }
1256 _ => return Err(ToolError::NotFound(format!("Unknown tool: {}", name))),
1257 };
1258 self.framework_builtin_tools.insert(name, tool);
1259 Ok(self)
1260 }
1261
1262 pub fn with_command_tool(mut self, name: &str) -> Result<Self, ToolError> {
1264 if name == "Bash" || name == "exec_command" {
1265 let (name, tool) = if name == "Bash" {
1266 BuiltinToolExecutor::register_tracked_builtin(&self.registry, BashTool::new())?
1267 } else {
1268 BuiltinToolExecutor::register_tracked_builtin(
1269 &self.registry,
1270 ExecCommandTool::new(),
1271 )?
1272 };
1273 self.framework_builtin_tools.insert(name, tool);
1274 return Ok(self);
1275 }
1276 match name {
1277 "write_stdin" => self.registry.register(WriteStdinTool::new()),
1278 "BashInput" => self.registry.register(BashInputTool::new()),
1279 "BashOutput" => self.registry.register(BashOutputTool::new()),
1280 "KillShell" => self.registry.register(KillShellTool::new()),
1281 "Task" => self.registry.register(TaskTool::new()),
1282 _ => return Err(ToolError::NotFound(format!("Unknown tool: {}", name))),
1283 }
1284 .map_err(|e| ToolError::Execution(e.to_string()))?;
1285 Ok(self)
1286 }
1287
1288 pub fn with_tool<T: Tool + 'static>(self, tool: T) -> Result<Self, ToolError> {
1290 self.registry
1291 .register(tool)
1292 .map_err(|e| ToolError::Execution(e.to_string()))?;
1293 Ok(self)
1294 }
1295
1296 pub fn with_permission_checker(mut self, checker: Arc<dyn PermissionChecker>) -> Self {
1298 self.permission_checker = Some(checker);
1299 self
1300 }
1301
1302 pub fn with_tool_event_publisher(mut self, publisher: Arc<dyn ToolEventPublisher>) -> Self {
1304 self.tool_event_publisher = publisher;
1305 self
1306 }
1307
1308 pub fn build(self) -> BuiltinToolExecutor {
1310 BuiltinToolExecutor {
1311 registry: self.registry,
1312 permission_checker: self.permission_checker,
1313 framework_builtin_tools: self.framework_builtin_tools,
1314 native_tool_ceiling: None,
1315 tool_event_publisher: self.tool_event_publisher,
1316 }
1317 }
1318}
1319
1320impl Default for BuiltinToolExecutorBuilder {
1321 fn default() -> Self {
1322 Self::new()
1323 }
1324}
1325
1326#[cfg(test)]
1327mod tests {
1328 use super::*;
1329 use bamboo_agent_core::AgentEvent;
1330 use bamboo_agent_core::FunctionCall;
1331 use bamboo_agent_core::ToolCtx;
1332 use bamboo_agent_core::ToolExecutionContext;
1333 use bamboo_domain::tool_names::{normalize_tool_ref, BUILTIN_TOOL_NAMES};
1334 use bamboo_plugin_protocol::{
1335 FileChangedV1, InMemoryToolEventRecorder, ToolEventContextV1, ToolEventPublishError,
1336 ToolEventV1, MAX_TOOL_EVENT_PATH_BYTES,
1337 };
1338 use serde_json::json;
1339 use std::sync::atomic::{AtomicUsize, Ordering};
1340 use std::sync::Arc;
1341 use tokio::fs;
1342 use tokio::sync::mpsc;
1343
1344 use crate::tools::WriteTool;
1345
1346 fn make_tool_call(name: &str, args: serde_json::Value) -> ToolCall {
1347 make_tool_call_with_id("call_1", name, args)
1348 }
1349
1350 #[test]
1351 fn malformed_private_arguments_redact_both_log_previews() {
1352 for (tool_name, raw) in [
1353 (
1354 "browser",
1355 r#"{"action":"type","text":"private browser input"#,
1356 ),
1357 (
1358 "default::browser",
1359 r#"{"action":"type","text":"private browser input"#,
1360 ),
1361 ("browser_eval", r#"{"code":"private page source"#),
1362 ("default::browser_eval", r#"{"code":"private page source"#),
1363 (
1364 "session_message",
1365 r#"{"action":"send","message":"private peer text"#,
1366 ),
1367 (
1368 "default::session_message",
1369 r#"{"action":"send","message":"private peer text"#,
1370 ),
1371 ] {
1372 let (_, warning) = parse_tool_args_best_effort(raw);
1373 let warning = warning.expect("malformed JSON must have a warning");
1374 assert!(warning.contains("private"));
1375 let (preview, logged_warning) = parse_warning_log_details(tool_name, raw, &warning);
1376 assert_eq!(preview, "[redacted]");
1377 assert_eq!(logged_warning, "[redacted]");
1378 let (ordinary_preview, ordinary_warning) =
1379 parse_warning_log_details("Write", raw, &warning);
1380 assert!(ordinary_preview.contains("private"));
1381 assert_eq!(ordinary_warning, warning);
1382 }
1383 }
1384
1385 #[test]
1386 fn peer_approval_event_hides_message_and_routing_without_changing_execution_args() {
1387 let args = json!({
1388 "action":"send", "target_session_id":"private-target",
1389 "operation_id":"private-operation", "message":"private-message",
1390 "reply_to":"private-received-id", "extra":{"secret":"private-extra"},
1391 });
1392 let original = args.clone();
1393 for name in ["session_message", "default::session_message"] {
1394 assert_eq!(
1395 approval_parameters_for_display(name, &args),
1396 json!({"action":"send"})
1397 );
1398 }
1399 assert_eq!(args, original);
1400 assert_eq!(approval_parameters_for_display("Write", &args), args);
1401 }
1402
1403 #[test]
1404 fn native_select_approval_event_hides_values_without_changing_execution_args() {
1405 let args = json!({
1406 "action":"select_option",
1407 "selector":"select[data-private='account']",
1408 "values":["private-option-value"],
1409 "expected_epoch":17,
1410 "text":"private-text",
1411 "url":"https://example.com/?private=query",
1412 "nested":{"secret":"private-nested"},
1413 });
1414 let original = args.clone();
1415 let display = approval_parameters_for_display("browser", &args);
1416 assert_eq!(display, json!({"action":"select_option"}));
1417 assert_eq!(
1418 approval_parameters_for_display("default::browser", &args),
1419 json!({"action":"select_option"})
1420 );
1421 assert_eq!(args, original);
1422 assert!(!display.to_string().contains("private-option-value"));
1423 assert!(!display.to_string().contains("data-private"));
1424 assert!(!display.to_string().contains("private-query"));
1425 assert!(!display.to_string().contains("private-nested"));
1426 assert_eq!(approval_parameters_for_display("Write", &args), args);
1427 }
1428
1429 #[test]
1430 fn browser_download_approval_event_hides_selector_and_extra_fields() {
1431 let args = json!({
1432 "action":"download",
1433 "selector":"a[data-secret='private-selector']",
1434 "expected_epoch":17,
1435 "url":"https://example.test/private-url",
1436 "extra":{"secret":"private-extra"},
1437 "data_base64":"private-file-bytes",
1438 });
1439 let original = args.clone();
1440 for name in ["browser", "default::browser", "private-selector::browser"] {
1441 assert_eq!(
1442 approval_parameters_for_display(name, &args),
1443 json!({"action":"download","expected_epoch":17})
1444 );
1445 assert_eq!(approval_tool_name_for_display(name), "browser");
1446 }
1447 assert_eq!(approval_tool_name_for_display("Read"), "Read");
1448 assert_eq!(
1449 args, original,
1450 "display projection must not change execution args"
1451 );
1452 let malformed_epoch = json!({
1453 "action":"download",
1454 "selector":"private-selector",
1455 "expected_epoch":"private-epoch",
1456 });
1457 assert_eq!(
1458 approval_parameters_for_display("browser", &malformed_epoch),
1459 json!({"action":"download"})
1460 );
1461 assert_eq!(approval_parameters_for_display("Write", &args), args);
1462 }
1463
1464 #[tokio::test]
1465 async fn browser_download_approval_projects_display_but_retains_exact_request() {
1466 struct CaptureApprovalProxy(Arc<std::sync::Mutex<Option<crate::approval::ApprovalAsk>>>);
1467
1468 #[async_trait]
1469 impl crate::approval::ApprovalProxy for CaptureApprovalProxy {
1470 async fn request_approval(&self, ask: crate::approval::ApprovalAsk) -> bool {
1471 *self.0.lock().expect("capture approval") = Some(ask);
1472 false
1473 }
1474 }
1475
1476 for tool_name in ["browser", "default::browser", "private-selector::browser"] {
1477 let args = json!({
1478 "action":"download",
1479 "selector":"a[data-secret='private-selector']",
1480 "expected_epoch":17,
1481 });
1482 let resource = crate::permission::check_permissions("browser", &args)
1483 .expect("valid browser permission")
1484 .expect("download requires approval")
1485 .remove(0)
1486 .resource;
1487 let config = Arc::new(crate::permission::PermissionConfig::new());
1488 let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(
1489 config.clone(),
1490 ));
1491 let executor = BuiltinToolExecutorBuilder::new()
1492 .with_tool(ExactRoutingTool {
1493 name: "browser",
1494 label: "should-not-run",
1495 args_sensitive: false,
1496 })
1497 .expect("register browser stub")
1498 .with_permission_checker(checker)
1499 .build();
1500 let call = make_tool_call(tool_name, args);
1501 let (tx, mut rx) = mpsc::channel(4);
1502 let context = ToolExecutionContext {
1503 session_id: Some("browser-download-approval"),
1504 event_tx: Some(&tx),
1505 ..ToolExecutionContext::none(&call.id)
1506 };
1507 let result = executor
1508 .execute_with_context(&call, context)
1509 .await
1510 .expect("download must pause for approval");
1511 let payload: serde_json::Value =
1512 serde_json::from_str(&result.result).expect("approval payload");
1513 assert_eq!(payload["status"], "awaiting_permission_approval");
1514 assert_eq!(
1515 payload["resource"],
1516 "Download from selected browser element"
1517 );
1518 assert!(payload["question"]
1519 .as_str()
1520 .unwrap()
1521 .contains("Download from selected browser element"));
1522 assert!(!payload["question"].as_str().unwrap().contains(&resource));
1523 assert!(!payload["question"]
1524 .as_str()
1525 .unwrap()
1526 .contains("private-selector"));
1527 assert_eq!(payload["permission_request"]["resource"], resource);
1528 assert_eq!(payload["permission_request"]["tool_name"], "browser");
1529 assert_eq!(call.function.name, tool_name);
1530 assert_eq!(
1531 config
1532 .pending_request("browser-download-approval", &call.id)
1533 .expect("authoritative pending request")
1534 .resource,
1535 resource
1536 );
1537 assert_eq!(
1538 config
1539 .pending_request("browser-download-approval", &call.id)
1540 .expect("authoritative pending request")
1541 .tool_name,
1542 "browser"
1543 );
1544 assert!(matches!(
1545 rx.recv().await.expect("approval event"),
1546 AgentEvent::ToolApprovalRequested { tool_name, parameters, .. }
1547 if tool_name == "browser"
1548 && parameters == json!({"action":"download","expected_epoch":17})
1549 ));
1550
1551 let captured = Arc::new(std::sync::Mutex::new(None));
1552 let proxy: Arc<dyn crate::approval::ApprovalProxy> =
1553 Arc::new(CaptureApprovalProxy(Arc::clone(&captured)));
1554 let relay_call = make_tool_call(
1555 tool_name,
1556 json!({
1557 "action":"download",
1558 "selector":"a[data-secret='private-selector']",
1559 "expected_epoch":17,
1560 }),
1561 );
1562 let relay_context = ToolExecutionContext {
1563 session_id: Some("browser-download-relay"),
1564 ..ToolExecutionContext::none(&relay_call.id)
1565 };
1566 let denied = crate::approval::with_approval_proxy(
1567 Some(proxy),
1568 executor.execute_with_context(&relay_call, relay_context),
1569 )
1570 .await
1571 .expect_err("host denied download");
1572 assert!(matches!(&denied, ToolError::Execution(_)));
1573 assert!(!denied.to_string().contains(&resource));
1574 let ask = captured
1575 .lock()
1576 .expect("captured proxy")
1577 .clone()
1578 .expect("host saw approval request");
1579 assert_eq!(ask.tool_name, "browser");
1580 assert_eq!(
1581 ask.permission_request.as_ref().unwrap().tool_name,
1582 "browser"
1583 );
1584 assert_eq!(ask.resource, "Download from selected browser element");
1585 assert_eq!(
1586 ask.permission_request.as_ref().unwrap().resource,
1587 "[redacted]"
1588 );
1589 assert!(ask
1590 .permission_request
1591 .unwrap()
1592 .suggested_matchers
1593 .is_empty());
1594 }
1595 }
1596
1597 #[test]
1598 fn browser_file_input_approval_event_hides_bytes_and_metadata() {
1599 let args = json!({
1600 "action":"set_file_input","selector":"#upload","filename":"private.txt",
1601 "mime_type":"text/plain","data_base64":"cHJpdmF0ZSBieXRlcw==",
1602 "expected_epoch":17,
1603 });
1604 let original = args.clone();
1605 for tool in ["browser", "default::browser"] {
1606 assert_eq!(
1607 approval_parameters_for_display(tool, &args),
1608 json!({"action":"set_file_input","file":"[redacted]"})
1609 );
1610 }
1611 assert_eq!(args, original);
1612 assert_eq!(approval_parameters_for_display("Write", &args), args);
1613 }
1614
1615 #[tokio::test]
1616 async fn oversized_write_is_rejected_before_creating_a_file() {
1617 let dir = tempfile::tempdir().unwrap();
1618 let file = dir.path().join("oversized.txt");
1619 let call = make_tool_call(
1620 "write_file",
1621 json!({
1622 "path": file,
1623 "content": "x".repeat(1024 * 1024),
1624 }),
1625 );
1626 let error = BuiltinToolExecutor::new().execute(&call).await.unwrap_err();
1627 assert!(matches!(error, ToolError::InvalidArguments(_)));
1628 assert!(!file.exists());
1629 }
1630
1631 fn make_tool_call_with_id(id: &str, name: &str, args: serde_json::Value) -> ToolCall {
1632 ToolCall {
1633 id: id.to_string(),
1634 tool_type: "function".to_string(),
1635 function: FunctionCall {
1636 name: name.to_string(),
1637 arguments: args.to_string(),
1638 },
1639 }
1640 }
1641
1642 fn tool_event_context<'a>(
1643 call: &'a ToolCall,
1644 session_id: Option<&'a str>,
1645 root_session_id: Option<&'a str>,
1646 ) -> ToolExecutionContext<'a> {
1647 ToolExecutionContext {
1648 executing_supervisor: None,
1649 session_id,
1650 root_session_id,
1651 tool_call_id: &call.id,
1652 event_tx: None,
1653 available_tool_schemas: None,
1654 bypass_permissions: false,
1655 auto_approve_permissions: false,
1656 plan_read_only: false,
1657 can_async_resume: false,
1658 bash_completion_sink: None,
1659 pre_parsed_args: None,
1660 }
1661 }
1662
1663 fn assert_single_file_changed(
1664 recorder: &InMemoryToolEventRecorder,
1665 session_id: &str,
1666 root_session_id: &str,
1667 tool_name: &str,
1668 tool_call_id: &str,
1669 path: &str,
1670 ) {
1671 let events = recorder.try_snapshot().expect("snapshot tool events");
1672 assert_eq!(
1673 events.len(),
1674 1,
1675 "successful mutation must emit exactly once"
1676 );
1677 let event = &events[0];
1678 assert_eq!(event.context.session_id, session_id);
1679 assert_eq!(event.context.root_session_id, root_session_id);
1680 assert_eq!(event.context.tool_name, tool_name);
1681 assert_eq!(event.context.tool_call_id, tool_call_id);
1682 assert_eq!(
1683 event
1684 .file_changed_data()
1685 .expect("known file_changed event")
1686 .expect("valid file_changed payload")
1687 .path,
1688 path
1689 );
1690 }
1691
1692 fn seed_event(call_id: &str) -> ToolEventV1 {
1693 ToolEventV1::file_changed(
1694 ToolEventContextV1::bounded("seed-session", "seed-root-session", "Write", call_id)
1695 .unwrap(),
1696 FileChangedV1::bounded("/seed/file.txt").unwrap(),
1697 )
1698 .unwrap()
1699 }
1700
1701 fn make_tool_call_with_raw_args(name: &str, raw_args: &str) -> ToolCall {
1702 ToolCall {
1703 id: "call_1".to_string(),
1704 tool_type: "function".to_string(),
1705 function: FunctionCall {
1706 name: name.to_string(),
1707 arguments: raw_args.to_string(),
1708 },
1709 }
1710 }
1711
1712 struct ReturningPublisher(ToolEventPublishError);
1713
1714 impl ToolEventPublisher for ReturningPublisher {
1715 fn try_publish(&self, _event: ToolEventV1) -> Result<(), ToolEventPublishError> {
1716 Err(self.0.clone())
1717 }
1718 }
1719
1720 struct IsEnabledPanicPublisher;
1721
1722 impl ToolEventPublisher for IsEnabledPanicPublisher {
1723 fn is_enabled(&self) -> bool {
1724 panic!("is_enabled publisher panic")
1725 }
1726
1727 fn try_publish(&self, _event: ToolEventV1) -> Result<(), ToolEventPublishError> {
1728 unreachable!("disabled publisher must not receive an event")
1729 }
1730 }
1731
1732 struct TryPublishPanicPublisher;
1733
1734 impl ToolEventPublisher for TryPublishPanicPublisher {
1735 fn try_publish(&self, _event: ToolEventV1) -> Result<(), ToolEventPublishError> {
1736 panic!("try_publish publisher panic")
1737 }
1738 }
1739
1740 struct StubWriteTool {
1741 success: bool,
1742 }
1743
1744 #[async_trait]
1745 impl Tool for StubWriteTool {
1746 fn name(&self) -> &str {
1747 "Write"
1748 }
1749
1750 fn description(&self) -> &str {
1751 "test-only custom tool that deliberately spoofs Write"
1752 }
1753
1754 fn parameters_schema(&self) -> serde_json::Value {
1755 json!({"type": "object", "properties": {"file_path": {"type": "string"}}})
1756 }
1757
1758 async fn invoke(
1759 &self,
1760 _args: serde_json::Value,
1761 _ctx: ToolCtx,
1762 ) -> Result<ToolOutcome, ToolError> {
1763 Ok(ToolOutcome::Completed(ToolResult {
1764 success: self.success,
1765 result: "stub-write-result".to_string(),
1766 display_preference: None,
1767 images: Vec::new(),
1768 }))
1769 }
1770 }
1771
1772 fn marked_stub_write_executor(
1773 success: bool,
1774 publisher: Arc<dyn ToolEventPublisher>,
1775 ) -> BuiltinToolExecutor {
1776 let registry = ToolRegistry::new();
1777 let tool: Arc<dyn Tool> = Arc::new(StubWriteTool { success });
1778 registry
1779 .register_shared(tool.clone())
1780 .expect("register stub Write");
1781 BuiltinToolExecutor {
1782 registry,
1783 permission_checker: None,
1784 framework_builtin_tools: BTreeMap::from([("Write".to_string(), tool)]),
1785 native_tool_ceiling: None,
1786 tool_event_publisher: publisher,
1787 }
1788 }
1789
1790 async fn assert_real_write_succeeds_with_publisher(
1791 publisher: Arc<dyn ToolEventPublisher>,
1792 label: &str,
1793 ) {
1794 let dir = tempfile::tempdir().unwrap();
1795 let path = dir.path().join(format!("publisher-{label}.txt"));
1796 let call = make_tool_call_with_id(
1797 &format!("publisher-{label}"),
1798 "Write",
1799 json!({"file_path": path, "content": label}),
1800 );
1801 let executor = BuiltinToolExecutorBuilder::new()
1802 .with_filesystem_tool("Write")
1803 .expect("register built-in Write")
1804 .with_tool_event_publisher(publisher)
1805 .build();
1806
1807 let result = executor
1808 .execute_with_context(
1809 &call,
1810 tool_event_context(
1811 &call,
1812 Some("publisher-session"),
1813 Some("publisher-root-session"),
1814 ),
1815 )
1816 .await
1817 .expect("publisher behavior must not turn tool success into an error");
1818
1819 assert!(
1820 result.success,
1821 "publisher must not alter ToolResult.success"
1822 );
1823 assert_eq!(fs::read_to_string(path).await.unwrap(), label);
1824 }
1825
1826 fn make_executor(
1827 permission_checker: Option<Arc<dyn PermissionChecker>>,
1828 ) -> BuiltinToolExecutor {
1829 let builder = BuiltinToolExecutorBuilder::new()
1830 .with_tool(WriteTool::new())
1831 .expect("register Write tool");
1832
1833 let builder = match permission_checker {
1834 Some(checker) => builder.with_permission_checker(checker),
1835 None => builder,
1836 };
1837
1838 builder.build()
1839 }
1840
1841 async fn permission_request_payload(
1842 executor: &BuiltinToolExecutor,
1843 session_id: &str,
1844 args: serde_json::Value,
1845 ) -> serde_json::Value {
1846 let (event_tx, _event_rx) = mpsc::channel(4);
1847 let call = make_tool_call("Write", args);
1848 let ctx = ToolExecutionContext {
1849 executing_supervisor: None,
1850 session_id: Some(session_id),
1851 root_session_id: None,
1852 tool_call_id: &call.id,
1853 event_tx: Some(&event_tx),
1854 available_tool_schemas: None,
1855 bypass_permissions: false,
1856 auto_approve_permissions: false,
1857 plan_read_only: false,
1858 can_async_resume: false,
1859 bash_completion_sink: None,
1860 pre_parsed_args: None,
1861 };
1862 let result = executor
1863 .execute_with_context(&call, ctx)
1864 .await
1865 .expect("interactive permission gate should pause");
1866 serde_json::from_str(&result.result).expect("typed permission payload")
1867 }
1868
1869 struct RecordingApprovalProxy {
1870 requests: Arc<AtomicUsize>,
1871 approve: bool,
1872 }
1873
1874 #[async_trait]
1875 impl crate::approval::ApprovalProxy for RecordingApprovalProxy {
1876 async fn request_approval(&self, _ask: crate::approval::ApprovalAsk) -> bool {
1877 self.requests.fetch_add(1, Ordering::SeqCst);
1878 self.approve
1879 }
1880 }
1881
1882 #[test]
1883 fn test_normalize_tool_ref_accepts_claude_style_names() {
1884 assert_eq!(
1885 normalize_tool_ref("default::Bash"),
1886 Some("Bash".to_string())
1887 );
1888 }
1889
1890 #[test]
1891 fn test_normalize_tool_ref_accepts_legacy_camel_aliases() {
1892 assert_eq!(
1893 normalize_tool_ref("default::fileExists"),
1894 Some("FileExists".to_string())
1895 );
1896 assert_eq!(
1897 normalize_tool_ref("default::getCurrentDir"),
1898 Some("GetCurrentDir".to_string())
1899 );
1900 assert_eq!(
1901 normalize_tool_ref("default::getFileInfo"),
1902 Some("GetFileInfo".to_string())
1903 );
1904 assert_eq!(
1905 normalize_tool_ref("default::setWorkspace"),
1906 Some("SetWorkspace".to_string())
1907 );
1908 assert_eq!(
1909 normalize_tool_ref("default::sleep"),
1910 Some("Sleep".to_string())
1911 );
1912 }
1913
1914 #[test]
1915 fn test_normalize_tool_ref_accepts_legacy_snake_case_aliases() {
1916 assert_eq!(
1917 normalize_tool_ref("default::execute_command"),
1918 Some("Bash".to_string())
1919 );
1920 assert_eq!(
1921 normalize_tool_ref("default::file_exists"),
1922 Some("FileExists".to_string())
1923 );
1924 assert_eq!(
1925 normalize_tool_ref("default::get_current_dir"),
1926 Some("GetCurrentDir".to_string())
1927 );
1928 assert_eq!(
1929 normalize_tool_ref("default::get_file_info"),
1930 Some("GetFileInfo".to_string())
1931 );
1932 assert_eq!(
1933 normalize_tool_ref("default::list_directory"),
1934 Some("Glob".to_string())
1935 );
1936 assert_eq!(
1937 normalize_tool_ref("default::memory_note"),
1938 Some("memory_note".to_string())
1939 );
1940 assert_eq!(
1941 normalize_tool_ref("default::read_file"),
1942 Some("Read".to_string())
1943 );
1944 assert_eq!(
1945 normalize_tool_ref("default::set_workspace"),
1946 Some("SetWorkspace".to_string())
1947 );
1948 assert_eq!(
1949 normalize_tool_ref("default::write_file"),
1950 Some("Write".to_string())
1951 );
1952 }
1953
1954 #[test]
1955 fn test_normalize_tool_ref_accepts_spawn_task_aliases() {
1956 for alias in [
1957 "default::spawn_session",
1958 "default::sub_session",
1959 "default::sub_task",
1960 "default::team_agent",
1961 "default::child_session",
1962 ] {
1963 assert_eq!(normalize_tool_ref(alias), Some("SubAgent".to_string()));
1964 }
1965 }
1966
1967 #[test]
1968 fn test_normalize_tool_ref_accepts_server_overlay_tools() {
1969 assert_eq!(normalize_tool_ref("compress_context"), None);
1970 assert_eq!(
1971 normalize_tool_ref("default::read_skill_resource"),
1972 Some("read_skill_resource".to_string())
1973 );
1974 }
1975
1976 #[tokio::test]
1977 async fn test_executor_accepts_legacy_read_file_path_argument() {
1978 let dir = tempfile::tempdir().unwrap();
1979 let file_path = dir.path().join("legacy-read.txt");
1980 fs::write(&file_path, "legacy read content").await.unwrap();
1981
1982 let executor = BuiltinToolExecutor::new();
1983 let call = make_tool_call("read_file", json!({"path": file_path}));
1984
1985 let result = executor.execute(&call).await.unwrap();
1986 assert!(result.success);
1987 assert!(result.result.contains("legacy read content"));
1988 }
1989
1990 #[tokio::test]
1991 async fn test_executor_accepts_legacy_list_directory_without_pattern() {
1992 let dir = tempfile::tempdir().unwrap();
1993 let file_path = dir.path().join("legacy-list.txt");
1994 fs::write(&file_path, "legacy list content").await.unwrap();
1995
1996 let executor = BuiltinToolExecutor::new();
1997 let call = make_tool_call("list_directory", json!({"path": dir.path()}));
1998
1999 let result = executor.execute(&call).await.unwrap();
2000 assert!(result.success);
2001 assert!(result.result.contains("legacy-list.txt"));
2002 }
2003
2004 #[tokio::test]
2005 async fn test_executor_accepts_canonical_read_with_path_argument() {
2006 let dir = tempfile::tempdir().unwrap();
2007 let file_path = dir.path().join("canonical-read.txt");
2008 fs::write(&file_path, "canonical read content")
2009 .await
2010 .unwrap();
2011
2012 let executor = BuiltinToolExecutor::new();
2013 let call = make_tool_call("Read", json!({"path": file_path}));
2014
2015 let result = executor.execute(&call).await.unwrap();
2016 assert!(result.success);
2017 assert!(result.result.contains("canonical read content"));
2018
2019 let namespaced = make_tool_call("default::Read", json!({"path": file_path}));
2020 let result = executor.execute(&namespaced).await.unwrap();
2021 assert!(result.success);
2022 assert!(result.result.contains("canonical read content"));
2023 }
2024
2025 #[tokio::test]
2026 async fn test_executor_accepts_canonical_glob_without_pattern_when_path_present() {
2027 let dir = tempfile::tempdir().unwrap();
2028 let file_path = dir.path().join("canonical-list.txt");
2029 fs::write(&file_path, "canonical list content")
2030 .await
2031 .unwrap();
2032
2033 let executor = BuiltinToolExecutor::new();
2034 let call = make_tool_call("Glob", json!({"path": dir.path()}));
2035
2036 let result = executor.execute(&call).await.unwrap();
2037 assert!(result.success);
2038 assert!(result.result.contains("canonical-list.txt"));
2039 }
2040
2041 #[test]
2042 fn test_executor_workspace_mutability_depends_on_path_argument() {
2043 let executor = BuiltinToolExecutor::new();
2044 let get_call = make_tool_call("Workspace", json!({}));
2045 let set_call = make_tool_call("Workspace", json!({"path": "/tmp"}));
2046
2047 assert_eq!(
2048 executor.call_mutability(&get_call),
2049 crate::ToolMutability::ReadOnly
2050 );
2051 assert!(executor.call_concurrency_safe(&get_call));
2052
2053 assert_eq!(
2054 executor.call_mutability(&set_call),
2055 crate::ToolMutability::Mutating
2056 );
2057 assert!(!executor.call_concurrency_safe(&set_call));
2058 }
2059
2060 #[test]
2061 fn call_parallel_classification_matches_individual_methods() {
2062 let executor = BuiltinToolExecutor::new();
2070 let cases: &[(&str, serde_json::Value)] = &[
2071 ("Read", json!({})),
2072 ("Grep", json!({"pattern": "x"})),
2073 (
2074 "Write",
2075 json!({"file_path": "/tmp/par_cls.txt", "content": "y"}),
2076 ),
2077 ("Bash", json!({"command": "echo hi"})),
2078 ("exec_command", json!({"cmd": "echo hi"})),
2079 ("write_stdin", json!({"session_id": "exec", "chars": ""})),
2080 ("write_stdin", json!({"session_id": "exec", "chars": "y\n"})),
2081 ("Workspace", json!({})),
2082 ("Workspace", json!({"path": "/tmp"})),
2083 ];
2084
2085 for (name, args) in cases {
2086 let call = make_tool_call(name, args.clone());
2087 let expected_mutability = executor.call_mutability(&call);
2088 let expected_concurrency = executor.call_concurrency_safe(&call);
2089 let (mutability, concurrency) = executor.call_parallel_classification(&call);
2090 assert_eq!(
2091 mutability, expected_mutability,
2092 "mutability mismatch for {name} ({args})"
2093 );
2094 assert_eq!(
2095 concurrency, expected_concurrency,
2096 "concurrency mismatch for {name} ({args})"
2097 );
2098 }
2099 }
2100
2101 #[test]
2102 fn list_tools_snapshot_is_stable_across_calls() {
2103 let executor = BuiltinToolExecutor::new();
2108 let first: Vec<String> = executor
2109 .list_tools()
2110 .into_iter()
2111 .map(|s| s.function.name)
2112 .collect();
2113 let second: Vec<String> = executor
2114 .list_tools()
2115 .into_iter()
2116 .map(|s| s.function.name)
2117 .collect();
2118 assert!(!first.is_empty(), "builtin executor should expose tools");
2119 assert_eq!(
2120 first, second,
2121 "list_tools() must be deterministic per round"
2122 );
2123 }
2124
2125 #[tokio::test]
2126 async fn test_executor_recovers_truncated_json_arguments() {
2127 let dir = tempfile::tempdir().unwrap();
2128 let path = dir.path().join("recovered-write.txt");
2129
2130 let malformed_args = format!(
2132 r#"{{"file_path":"{}","content":"recovered content""#,
2133 path.display()
2134 );
2135
2136 let executor = BuiltinToolExecutor::new();
2137 let call = make_tool_call_with_raw_args("Write", &malformed_args);
2138
2139 let result = executor
2140 .execute(&call)
2141 .await
2142 .expect("truncated JSON should be auto-repaired");
2143 assert!(result.success);
2144
2145 let written = fs::read_to_string(&path)
2146 .await
2147 .expect("file should be written");
2148 assert_eq!(written, "recovered content");
2149 }
2150
2151 #[test]
2152 fn test_normalize_tool_ref_rejects_unknown_tool() {
2153 assert_eq!(normalize_tool_ref("default::search"), None);
2154 }
2155
2156 #[test]
2157 fn test_executor_does_not_expose_legacy_tools() {
2158 let executor = BuiltinToolExecutor::new();
2159 let tool_names: Vec<String> = executor
2160 .list_tools()
2161 .into_iter()
2162 .map(|schema| schema.function.name)
2163 .collect();
2164
2165 for legacy in ["claude_code", "search_in_file", "search_in_project"] {
2166 assert!(!tool_names.iter().any(|name| name == legacy));
2167 }
2168 }
2169
2170 #[test]
2171 fn test_critical_tool_schemas_match_claude_shapes() {
2172 let executor = BuiltinToolExecutor::new();
2173 let tools = executor.list_tools();
2174
2175 let get_params = |name: &str| {
2176 tools
2177 .iter()
2178 .find(|tool| tool.function.name == name)
2179 .unwrap()
2180 .function
2181 .parameters
2182 .clone()
2183 };
2184
2185 let grep = get_params("Grep");
2186 assert_eq!(grep["required"], json!(["pattern"]));
2187 assert_eq!(
2188 grep["properties"]["output_mode"]["enum"],
2189 json!(["content", "files_with_matches", "count"])
2190 );
2191 assert!(grep["properties"]["-A"].is_object());
2192 assert!(grep["properties"]["-B"].is_object());
2193 assert!(grep["properties"]["-C"].is_object());
2194 assert!(grep["properties"]["-n"].is_object());
2195 assert!(grep["properties"]["-i"].is_object());
2196
2197 let edit = get_params("Edit");
2198 assert_eq!(edit["required"], json!(["file_path"]));
2199 assert_eq!(edit["properties"]["old_string"]["type"], "string");
2200 assert_eq!(edit["properties"]["new_string"]["type"], "string");
2201 assert_eq!(edit["properties"]["patch"]["type"], "string");
2202 assert_eq!(edit["properties"]["replace_all"]["type"], "boolean");
2203 assert!(edit.get("oneOf").is_none());
2204
2205 assert_eq!(edit["properties"]["patch"]["type"], "string");
2208 assert_eq!(edit["properties"]["line_number"]["type"], "integer");
2209
2210 let bash = get_params("Bash");
2211 assert_eq!(bash["required"], json!(["command"]));
2212 assert_eq!(bash["properties"]["run_in_background"]["type"], "boolean");
2213 assert_eq!(bash["properties"]["workdir"]["type"], "string");
2214
2215 let bash_output = get_params("BashOutput");
2216 assert_eq!(bash_output["required"], json!(["bash_id"]));
2217 assert_eq!(bash_output["properties"]["filter"]["type"], "string");
2218 }
2219
2220 #[test]
2221 fn test_tool_schemas_avoid_openai_forbidden_top_level_keywords() {
2222 let executor = BuiltinToolExecutor::new();
2223 let tools = executor.list_tools();
2224 let forbidden = ["oneOf", "anyOf", "allOf", "not", "enum"];
2225
2226 for tool in tools {
2227 let params = &tool.function.parameters;
2228 assert_eq!(
2229 params["type"], "object",
2230 "tool '{}' parameters must be a top-level object schema",
2231 tool.function.name
2232 );
2233 for key in forbidden {
2234 assert!(
2235 params.get(key).is_none(),
2236 "tool '{}' parameters contains forbidden top-level keyword '{}'",
2237 tool.function.name,
2238 key
2239 );
2240 }
2241 }
2242 }
2243
2244 #[test]
2245 fn test_executor_has_all_builtin_tools() {
2246 let executor = BuiltinToolExecutor::new();
2247 let tools = executor.list_tools();
2248
2249 assert_eq!(tools.len(), BUILTIN_TOOL_NAMES.len());
2250
2251 let tool_names: Vec<String> = tools.iter().map(|t| t.function.name.clone()).collect();
2252 for tool_name in BUILTIN_TOOL_NAMES {
2253 assert!(tool_names.contains(&tool_name.to_string()));
2254 }
2255 }
2256
2257 #[test]
2258 fn retired_tools_are_not_registered() {
2259 let executor = BuiltinToolExecutor::new();
2260 let tool_names = executor.registry().list_tool_names();
2261
2262 for retired in [
2263 "EnterPlanMode",
2264 "js_repl",
2265 "NotebookEdit",
2266 "SlashCommand",
2267 "WebSearch",
2268 "conclusion_with_options",
2269 ] {
2270 assert!(!tool_names.iter().any(|name| name == retired), "{retired}");
2271 }
2272 }
2273
2274 #[test]
2275 fn test_executor_builds_enhanced_prompt() {
2276 let executor = BuiltinToolExecutor::new();
2277 let prompt = executor.build_enhanced_prompt(GuideBuildContext::default());
2278 assert!(prompt.contains("## Tool Usage Guidelines"));
2279 assert!(prompt.contains("**Read**"));
2280 }
2281
2282 #[test]
2283 fn test_executor_builder_empty() {
2284 let executor = BuiltinToolExecutorBuilder::new().build();
2285 assert!(executor.list_tools().is_empty());
2286 }
2287
2288 #[test]
2289 fn test_executor_builder_with_default_tools() {
2290 let executor = BuiltinToolExecutorBuilder::new()
2291 .with_default_tools()
2292 .build();
2293 assert_eq!(executor.list_tools().len(), BUILTIN_TOOL_NAMES.len());
2294 }
2295
2296 #[test]
2297 fn test_executor_builder_with_specific_tool() {
2298 let executor = BuiltinToolExecutorBuilder::new()
2299 .with_filesystem_tool("Read")
2300 .unwrap()
2301 .build();
2302
2303 let tools = executor.list_tools();
2304 assert_eq!(tools.len(), 1);
2305 assert_eq!(tools[0].function.name, "Read");
2306 }
2307
2308 #[tokio::test]
2309 async fn test_executor_skips_permission_checks_without_checker() {
2310 let executor = make_executor(None);
2311 let path = "/tmp/executor_permission_none.txt";
2312 let _ = fs::remove_file(path).await;
2313
2314 let call = make_tool_call("Write", json!({"file_path": path, "content": "ok"}));
2315 let result = executor.execute(&call).await.expect("execute tool");
2316
2317 assert!(result.success);
2318 let _ = fs::remove_file(path).await;
2319 }
2320
2321 #[tokio::test]
2322 async fn test_executor_with_permission_checker_enforces_checks() {
2323 let checker = Arc::new(crate::permission::DenyDangerousPermissionChecker);
2324 let executor = make_executor(Some(checker));
2325 let path = "/tmp/executor_permission_denied.txt";
2326 let _ = fs::remove_file(path).await;
2327
2328 let call = make_tool_call("Write", json!({"file_path": path, "content": "nope"}));
2329 let result = executor.execute(&call).await;
2330
2331 assert!(matches!(result, Err(ToolError::Execution(_))));
2332 assert!(fs::metadata(path).await.is_err());
2333 }
2334
2335 #[tokio::test]
2336 async fn test_bypass_permissions_skips_checker() {
2337 let config = Arc::new(crate::permission::PermissionConfig::new());
2343 let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
2344 let executor = BuiltinToolExecutorBuilder::new()
2345 .with_tool(BashTool::new())
2346 .expect("register Bash tool")
2347 .with_permission_checker(checker)
2348 .build();
2349 let dir = tempfile::tempdir().unwrap();
2350 let path = dir.path().join("bypass_allows_bash.txt");
2351 let command = format!("printf ordinary > {}", path.display());
2352 let approval_requests = Arc::new(AtomicUsize::new(0));
2353 let proxy: Arc<dyn crate::approval::ApprovalProxy> = Arc::new(RecordingApprovalProxy {
2354 requests: approval_requests.clone(),
2355 approve: true,
2356 });
2357 let (event_tx, mut event_rx) = mpsc::channel(8);
2358
2359 let call = make_tool_call("Bash", json!({"command": command}));
2360 let ctx = ToolExecutionContext {
2361 executing_supervisor: None,
2362 session_id: Some("s-bypass"),
2363 root_session_id: None,
2364 tool_call_id: &call.id,
2365 event_tx: Some(&event_tx),
2366 available_tool_schemas: None,
2367 bypass_permissions: true,
2368 auto_approve_permissions: false,
2369 plan_read_only: false,
2370 can_async_resume: false,
2371 bash_completion_sink: None,
2372 pre_parsed_args: None,
2373 };
2374 let result = crate::approval::with_approval_proxy(
2375 Some(proxy),
2376 executor.execute_with_context(&call, ctx),
2377 )
2378 .await;
2379
2380 assert!(result.is_ok(), "bypass should allow the write: {result:?}");
2381 assert_eq!(fs::read_to_string(&path).await.unwrap(), "ordinary");
2382 assert_eq!(
2383 approval_requests.load(Ordering::SeqCst),
2384 0,
2385 "ordinary bypassed child command must not invoke the parent reviewer"
2386 );
2387 assert!(
2388 event_rx.try_recv().is_err(),
2389 "ordinary bypassed child command must not emit a human approval event"
2390 );
2391 }
2392
2393 #[tokio::test]
2394 async fn hook_allow_skips_configured_ask_for_exact_call() {
2395 let dir = tempfile::tempdir().unwrap();
2396 let path = dir.path().join("hook-allowed.txt");
2397 let path_str = path.to_str().unwrap().to_string();
2398 let config = Arc::new(crate::permission::PermissionConfig::new());
2399 config.set_ask_rules([format!("Write({}/**)", dir.path().to_str().unwrap())]);
2400 let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
2401 let executor = make_executor(Some(checker));
2402 let call = make_tool_call(
2403 "Write",
2404 json!({"file_path": path_str, "content": "allowed by hook"}),
2405 );
2406 let ctx = ToolExecutionContext {
2407 executing_supervisor: None,
2408 session_id: Some("s-hook-allow"),
2409 root_session_id: None,
2410 tool_call_id: &call.id,
2411 event_tx: None,
2412 available_tool_schemas: None,
2413 bypass_permissions: false,
2414 auto_approve_permissions: false,
2415 plan_read_only: false,
2416 can_async_resume: false,
2417 bash_completion_sink: None,
2418 pre_parsed_args: None,
2419 };
2420
2421 let result = crate::with_hook_permission_override(
2422 Some(crate::HookPermissionOverride::Allow),
2423 &call.id,
2424 executor.execute_with_context(&call, ctx),
2425 )
2426 .await;
2427
2428 assert!(
2429 result.is_ok(),
2430 "hook allow should skip ordinary ask: {result:?}"
2431 );
2432 assert_eq!(fs::read_to_string(path).await.unwrap(), "allowed by hook");
2433 assert_eq!(
2434 crate::current_hook_permission_override(&call.id),
2435 None,
2436 "the one-call override must not leak"
2437 );
2438 }
2439
2440 #[tokio::test]
2441 async fn hook_allow_cannot_skip_hard_dangerous_parent_review() {
2442 let config = Arc::new(crate::permission::PermissionConfig::new());
2443 let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
2444 let executor = BuiltinToolExecutorBuilder::new()
2445 .with_tool(BashTool::new())
2446 .expect("register Bash tool")
2447 .with_permission_checker(checker)
2448 .build();
2449 let dir = tempfile::tempdir().unwrap();
2450 let path = dir.path().join("hard-dangerous-must-not-run.txt");
2451 let command = format!("eval 'printf denied > {}'", path.display());
2452 let requests = Arc::new(AtomicUsize::new(0));
2453 let proxy: Arc<dyn crate::approval::ApprovalProxy> = Arc::new(RecordingApprovalProxy {
2454 requests: requests.clone(),
2455 approve: false,
2456 });
2457 let call = make_tool_call("Bash", json!({"command": command}));
2458 let ctx = ToolExecutionContext {
2459 executing_supervisor: None,
2460 session_id: Some("s-hook-hard-dangerous"),
2461 root_session_id: None,
2462 tool_call_id: &call.id,
2463 event_tx: None,
2464 available_tool_schemas: None,
2465 bypass_permissions: true,
2466 auto_approve_permissions: false,
2467 plan_read_only: false,
2468 can_async_resume: false,
2469 bash_completion_sink: None,
2470 pre_parsed_args: None,
2471 };
2472
2473 let result = crate::with_hook_permission_override(
2474 Some(crate::HookPermissionOverride::Allow),
2475 &call.id,
2476 crate::approval::with_approval_proxy(
2477 Some(proxy),
2478 executor.execute_with_context(&call, ctx),
2479 ),
2480 )
2481 .await;
2482
2483 assert!(
2484 matches!(result, Err(ToolError::Execution(ref message)) if message.contains("denied by host")),
2485 "hard-dangerous review must remain authoritative: {result:?}"
2486 );
2487 assert_eq!(requests.load(Ordering::SeqCst), 1);
2488 assert!(!path.exists());
2489 }
2490
2491 #[tokio::test]
2492 async fn configless_checker_still_asks_for_focused_browser_input_under_bypass_and_hook_allow() {
2493 struct ConfiglessPromptChecker(Arc<AtomicUsize>);
2494
2495 #[async_trait]
2496 impl crate::permission::PermissionChecker for ConfiglessPromptChecker {
2497 async fn needs_confirmation(
2498 &self,
2499 _permission_type: crate::permission::PermissionType,
2500 _resource: &str,
2501 ) -> bool {
2502 false
2505 }
2506
2507 async fn request_confirmation(
2508 &self,
2509 context: crate::permission::PermissionContext,
2510 ) -> Result<bool, crate::permission::PermissionError> {
2511 self.0.fetch_add(1, Ordering::SeqCst);
2512 Err(crate::permission::PermissionError::confirmation_required(
2513 context,
2514 ))
2515 }
2516
2517 fn grant_session_permission(
2518 &self,
2519 _permission_type: crate::permission::PermissionType,
2520 _resource: String,
2521 ) {
2522 }
2523 }
2524
2525 let requests = Arc::new(AtomicUsize::new(0));
2526 let executor = BuiltinToolExecutorBuilder::new()
2527 .with_tool(ExactRoutingTool {
2528 name: "browser",
2529 label: "browser-was-invoked",
2530 args_sensitive: false,
2531 })
2532 .expect("register browser stub")
2533 .with_permission_checker(Arc::new(ConfiglessPromptChecker(Arc::clone(&requests))))
2534 .build();
2535
2536 for (index, args) in [
2537 json!({"action":"type","text":"private text","expected_epoch":17}),
2538 json!({"action":"key","key":"private-key","expected_epoch":17}),
2539 json!({"action":"press","key":"private-key","expected_epoch":17}),
2540 ]
2541 .into_iter()
2542 .enumerate()
2543 {
2544 let call = make_tool_call_with_id(&format!("focused-{index}"), "browser", args);
2545 let (event_tx, mut event_rx) = mpsc::channel(4);
2546 let ctx = ToolExecutionContext {
2547 executing_supervisor: None,
2548 session_id: Some("configless-browser"),
2549 root_session_id: None,
2550 tool_call_id: &call.id,
2551 event_tx: Some(&event_tx),
2552 available_tool_schemas: None,
2553 bypass_permissions: true,
2554 auto_approve_permissions: false,
2555 plan_read_only: false,
2556 can_async_resume: false,
2557 bash_completion_sink: None,
2558 pre_parsed_args: None,
2559 };
2560 let result = crate::with_hook_permission_override(
2561 Some(crate::HookPermissionOverride::Allow),
2562 &call.id,
2563 executor.execute_with_context(&call, ctx),
2564 )
2565 .await
2566 .expect("focused browser input must pause for approval");
2567 let payload: serde_json::Value =
2568 serde_json::from_str(&result.result).expect("approval payload");
2569 assert_eq!(payload["status"], "awaiting_permission_approval");
2570 assert_eq!(
2571 payload["permission_request"]["allowed_decisions"],
2572 json!(["allow_once", "deny_once"])
2573 );
2574 assert_eq!(
2575 payload["permission_request"]["suggested_matchers"],
2576 json!([])
2577 );
2578 let displayed = payload.to_string();
2579 assert!(!displayed.contains("private-key"));
2580 assert!(!displayed.contains("private text"));
2581 let event = event_rx.recv().await.expect("approval event");
2582 assert!(matches!(
2583 event,
2584 AgentEvent::ToolApprovalRequested { parameters, .. }
2585 if parameters.get("text").is_none() && parameters.get("key").is_none()
2586 ));
2587 assert_eq!(requests.load(Ordering::SeqCst), index + 1);
2588 }
2589
2590 let selector_call = make_tool_call(
2591 "browser",
2592 json!({"action":"press","selector":"#save","key":"Enter","expected_epoch":17}),
2593 );
2594 let ctx = ToolExecutionContext {
2595 session_id: Some("configless-browser"),
2596 bypass_permissions: true,
2597 ..ToolExecutionContext::none(&selector_call.id)
2598 };
2599 let result = executor
2600 .execute_with_context(&selector_call, ctx)
2601 .await
2602 .expect("selector-bound press keeps compatibility bypass");
2603 assert!(result.success);
2604 assert_eq!(requests.load(Ordering::SeqCst), 3);
2605 }
2606
2607 #[tokio::test]
2608 async fn focused_browser_type_approval_redacts_display_and_ignores_hook_allow() {
2609 struct NeverInvokeBrowser;
2610
2611 #[async_trait]
2612 impl Tool for NeverInvokeBrowser {
2613 fn name(&self) -> &str {
2614 "browser"
2615 }
2616
2617 fn description(&self) -> &str {
2618 "test browser approval boundary"
2619 }
2620
2621 fn parameters_schema(&self) -> serde_json::Value {
2622 json!({"type":"object"})
2623 }
2624
2625 async fn invoke(
2626 &self,
2627 _args: serde_json::Value,
2628 _ctx: ToolCtx,
2629 ) -> Result<ToolOutcome, ToolError> {
2630 panic!("focused browser input must pause before invocation")
2631 }
2632 }
2633
2634 struct CaptureApprovalProxy(Arc<std::sync::Mutex<Option<crate::approval::ApprovalAsk>>>);
2635
2636 #[async_trait]
2637 impl crate::approval::ApprovalProxy for CaptureApprovalProxy {
2638 async fn request_approval(&self, ask: crate::approval::ApprovalAsk) -> bool {
2639 *self.0.lock().expect("capture approval") = Some(ask);
2640 false
2641 }
2642 }
2643
2644 let args = json!({"action":"type","text":"private browser text","expected_epoch":17});
2645 let context = crate::permission::check_permissions("browser", &args)
2646 .expect("valid browser permission")
2647 .expect("browser interaction needs approval")
2648 .remove(0);
2649 let private_resource = context.resource.clone();
2650 let config = Arc::new(crate::permission::PermissionConfig::new());
2651 config
2652 .grant_typed_scoped_session_permission(
2653 "browser-redaction",
2654 context.permission_type,
2655 crate::permission::conservative_matchers(
2656 context.permission_type,
2657 &private_resource,
2658 )
2659 .remove(0),
2660 )
2661 .expect("remembered grant");
2662 let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
2663 let executor = BuiltinToolExecutorBuilder::new()
2664 .with_tool(NeverInvokeBrowser)
2665 .expect("register browser stub")
2666 .with_permission_checker(checker)
2667 .build();
2668 let call = make_tool_call("browser", args);
2669 let (tx, mut rx) = mpsc::channel(4);
2670 let interactive = ToolExecutionContext {
2671 executing_supervisor: None,
2672 session_id: Some("browser-redaction"),
2673 root_session_id: None,
2674 tool_call_id: &call.id,
2675 event_tx: Some(&tx),
2676 available_tool_schemas: None,
2677 bypass_permissions: true,
2678 auto_approve_permissions: false,
2679 plan_read_only: false,
2680 can_async_resume: false,
2681 bash_completion_sink: None,
2682 pre_parsed_args: None,
2683 };
2684 let result = crate::with_hook_permission_override(
2685 Some(crate::HookPermissionOverride::Allow),
2686 &call.id,
2687 executor.execute_with_context(&call, interactive),
2688 )
2689 .await
2690 .expect("focused input must pause even under hook allow and bypass");
2691 let payload: serde_json::Value =
2692 serde_json::from_str(&result.result).expect("approval payload");
2693 let question = payload["question"].as_str().expect("visible question");
2694 assert_eq!(payload["resource"], "Type into focused browser element");
2695 assert!(!question.contains("private browser text"));
2696 assert!(!question.contains(&private_resource));
2697 assert_eq!(
2698 payload["permission_request"]["resource"], private_resource,
2699 "the typed request still binds the exact parked invocation"
2700 );
2701 assert_eq!(
2702 payload["permission_request"]["allowed_decisions"],
2703 json!(["allow_once", "deny_once"])
2704 );
2705 let event = rx.recv().await.expect("approval event");
2706 assert!(matches!(
2707 event,
2708 AgentEvent::ToolApprovalRequested { parameters, .. }
2709 if parameters.get("text").is_none()
2710 ));
2711
2712 let captured = Arc::new(std::sync::Mutex::new(None));
2713 let proxy: Arc<dyn crate::approval::ApprovalProxy> =
2714 Arc::new(CaptureApprovalProxy(Arc::clone(&captured)));
2715 let delegated = ToolExecutionContext {
2716 executing_supervisor: None,
2717 session_id: Some("browser-redaction"),
2718 root_session_id: None,
2719 tool_call_id: &call.id,
2720 event_tx: None,
2721 available_tool_schemas: None,
2722 bypass_permissions: true,
2723 auto_approve_permissions: false,
2724 plan_read_only: false,
2725 can_async_resume: false,
2726 bash_completion_sink: None,
2727 pre_parsed_args: None,
2728 };
2729 let denied = crate::with_hook_permission_override(
2730 Some(crate::HookPermissionOverride::Allow),
2731 &call.id,
2732 crate::approval::with_approval_proxy(
2733 Some(proxy),
2734 executor.execute_with_context(&call, delegated),
2735 ),
2736 )
2737 .await;
2738 assert!(matches!(denied, Err(ToolError::Execution(_))));
2739 let ask = captured
2740 .lock()
2741 .expect("captured proxy")
2742 .clone()
2743 .expect("host saw approval request");
2744 assert_eq!(ask.resource, "Type into focused browser element");
2745 assert!(!ask.resource.contains("private browser text"));
2746 assert!(!ask.resource.contains(&private_resource));
2747 let delegated_request = ask.permission_request.expect("delegated typed request");
2748 assert_eq!(delegated_request.resource, "[redacted]");
2749 assert!(delegated_request.suggested_matchers.is_empty());
2750 }
2751
2752 #[tokio::test]
2753 async fn browser_eval_approval_hides_source_url_query_and_private_resource() {
2754 struct PromptChecker;
2755
2756 #[async_trait]
2757 impl crate::permission::PermissionChecker for PromptChecker {
2758 async fn needs_confirmation(
2759 &self,
2760 _permission_type: crate::permission::PermissionType,
2761 _resource: &str,
2762 ) -> bool {
2763 true
2764 }
2765
2766 async fn request_confirmation(
2767 &self,
2768 context: crate::permission::PermissionContext,
2769 ) -> Result<bool, crate::permission::PermissionError> {
2770 Err(crate::permission::PermissionError::confirmation_required(
2771 context,
2772 ))
2773 }
2774
2775 fn grant_session_permission(
2776 &self,
2777 _permission_type: crate::permission::PermissionType,
2778 _resource: String,
2779 ) {
2780 }
2781 }
2782
2783 struct CaptureProxy(Arc<std::sync::Mutex<Option<crate::approval::ApprovalAsk>>>);
2784
2785 #[async_trait]
2786 impl crate::approval::ApprovalProxy for CaptureProxy {
2787 async fn request_approval(&self, ask: crate::approval::ApprovalAsk) -> bool {
2788 *self.0.lock().expect("capture approval") = Some(ask);
2789 false
2790 }
2791 }
2792
2793 let args = json!({
2794 "code":"document.title = 'private-source'",
2795 "expected_url":"https://example.com/?token=private-query",
2796 "expected_epoch":17,
2797 });
2798 let private_resource = crate::permission::check_permissions("browser_eval", &args)
2799 .expect("valid eval permission")
2800 .expect("eval needs approval")
2801 .remove(0)
2802 .resource;
2803 let executor = BuiltinToolExecutorBuilder::new()
2804 .with_tool(ExactRoutingTool {
2805 name: "browser_eval",
2806 label: "eval-was-invoked",
2807 args_sensitive: false,
2808 })
2809 .expect("register eval stub")
2810 .with_permission_checker(Arc::new(PromptChecker))
2811 .build();
2812 let call = make_tool_call("browser_eval", args);
2813 let (tx, mut rx) = mpsc::channel(4);
2814 let interactive = ToolExecutionContext {
2815 session_id: Some("browser-eval-approval"),
2816 event_tx: Some(&tx),
2817 ..ToolExecutionContext::none(&call.id)
2818 };
2819 let result = executor
2820 .execute_with_context(&call, interactive)
2821 .await
2822 .expect("eval pauses for approval");
2823 let payload: serde_json::Value = serde_json::from_str(&result.result).unwrap();
2824 assert_eq!(payload["status"], "awaiting_permission_approval");
2825 assert_eq!(payload["resource"], "Execute browser page JavaScript");
2826 assert_eq!(payload["permission_request"]["resource"], private_resource);
2827 for secret in ["private-source", "private-query", &private_resource] {
2828 assert!(!payload["question"].as_str().unwrap().contains(secret));
2829 }
2830 let event = rx.recv().await.expect("approval event");
2831 assert!(matches!(
2832 event,
2833 AgentEvent::ToolApprovalRequested { parameters, .. }
2834 if parameters["code"] == "[redacted]"
2835 && parameters["expected_url"] == "[redacted]"
2836 && parameters["expected_epoch"] == 17
2837 && !parameters.to_string().contains("private-")
2838 ));
2839
2840 let captured = Arc::new(std::sync::Mutex::new(None));
2841 let proxy: Arc<dyn crate::approval::ApprovalProxy> =
2842 Arc::new(CaptureProxy(Arc::clone(&captured)));
2843 let delegated = ToolExecutionContext {
2844 session_id: Some("browser-eval-approval"),
2845 ..ToolExecutionContext::none(&call.id)
2846 };
2847 let denied = crate::approval::with_approval_proxy(
2848 Some(proxy),
2849 executor.execute_with_context(&call, delegated),
2850 )
2851 .await;
2852 assert!(matches!(denied, Err(ToolError::Execution(_))));
2853 let ask = captured.lock().unwrap().clone().expect("approval request");
2854 assert_eq!(ask.resource, "Execute browser page JavaScript");
2855 let display_request = ask.permission_request.as_ref().expect("typed request");
2856 assert_eq!(display_request.resource, "[redacted]");
2857 assert_eq!(
2858 display_request.operation_summary,
2859 "Execute browser page JavaScript"
2860 );
2861 assert!(display_request.suggested_matchers.is_empty());
2862 for secret in ["private-source", "private-query", &private_resource] {
2863 assert!(!format!("{ask:?}").contains(secret));
2864 }
2865 }
2866
2867 #[tokio::test]
2868 async fn private_browser_approval_errors_hide_exact_resource_in_both_checker_paths() {
2869 enum Failure {
2870 Denied,
2871 Error,
2872 HardDeny,
2873 }
2874
2875 struct FailingChecker {
2876 failure: Failure,
2877 config: Option<Arc<crate::permission::PermissionConfig>>,
2878 }
2879
2880 #[async_trait]
2881 impl crate::permission::PermissionChecker for FailingChecker {
2882 async fn needs_confirmation(
2883 &self,
2884 _permission_type: crate::permission::PermissionType,
2885 _resource: &str,
2886 ) -> bool {
2887 true
2888 }
2889
2890 async fn request_confirmation(
2891 &self,
2892 context: crate::permission::PermissionContext,
2893 ) -> Result<bool, crate::permission::PermissionError> {
2894 match self.failure {
2895 Failure::Denied => Ok(false),
2896 Failure::Error => Err(crate::permission::PermissionError::CheckFailed(
2897 context.resource,
2898 )),
2899 Failure::HardDeny => panic!("hard deny must stop before confirmation"),
2900 }
2901 }
2902
2903 fn grant_session_permission(
2904 &self,
2905 _permission_type: crate::permission::PermissionType,
2906 _resource: String,
2907 ) {
2908 }
2909
2910 fn permission_config(&self) -> Option<Arc<crate::permission::PermissionConfig>> {
2911 self.config.clone()
2912 }
2913
2914 fn hard_deny_reason(
2915 &self,
2916 context: &crate::permission::PermissionContext,
2917 ) -> Option<String> {
2918 matches!(self.failure, Failure::HardDeny).then(|| context.resource.clone())
2919 }
2920 }
2921
2922 for (tool_name, args, secrets) in [
2923 (
2924 "browser",
2925 json!({"action":"type","text":"private browser text","expected_epoch":17}),
2926 &["private browser text"][..],
2927 ),
2928 (
2929 "browser_eval",
2930 json!({"code":"private page source","expected_url":"https://example.com/?token=private-query","expected_epoch":17}),
2931 &["private page source", "private-query"][..],
2932 ),
2933 (
2934 "browser",
2935 json!({"action":"download","selector":"a[data-secret='private-selector']","expected_epoch":17}),
2936 &["private-selector"][..],
2937 ),
2938 ] {
2939 let private_resource = crate::permission::check_permissions(tool_name, &args)
2940 .expect("valid browser permission")
2941 .expect("browser interaction needs approval")
2942 .remove(0)
2943 .resource;
2944 for (label, failure, config) in [
2945 ("configless-denied", Failure::Denied, None),
2946 ("configless-error", Failure::Error, None),
2947 ("configless-hard-deny", Failure::HardDeny, None),
2948 (
2949 "config-backed-hard-deny",
2950 Failure::HardDeny,
2951 Some(Arc::new(crate::permission::PermissionConfig::new())),
2952 ),
2953 ] {
2954 let executor = BuiltinToolExecutorBuilder::new()
2955 .with_tool(ExactRoutingTool {
2956 name: tool_name,
2957 label: "browser-was-invoked",
2958 args_sensitive: false,
2959 })
2960 .expect("register browser stub")
2961 .with_permission_checker(Arc::new(FailingChecker { failure, config }))
2962 .build();
2963 let call = make_tool_call_with_id(label, tool_name, args.clone());
2964 let ctx = ToolExecutionContext {
2965 session_id: Some("private-browser-error"),
2966 ..ToolExecutionContext::none(&call.id)
2967 };
2968 let error = executor
2969 .execute_with_context(&call, ctx)
2970 .await
2971 .expect_err(label);
2972 let displayed = error.to_string();
2973 assert!(
2974 !displayed.contains(&private_resource),
2975 "{label}: {displayed}"
2976 );
2977 for secret in secrets {
2978 assert!(!displayed.contains(secret), "{label}: {displayed}");
2979 }
2980 }
2981 }
2982 }
2983
2984 #[tokio::test]
2985 async fn hook_allow_cannot_skip_explicit_deny() {
2986 let dir = tempfile::tempdir().unwrap();
2987 let path = dir.path().join("explicit-deny.txt");
2988 let path_str = path.to_str().unwrap().to_string();
2989 let config = Arc::new(crate::permission::PermissionConfig::new());
2990 config.deny_scoped_session_permission(
2991 "s-hook-explicit-deny",
2992 crate::permission::PermissionType::WriteFile,
2993 path_str.clone(),
2994 );
2995 let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
2996 let executor = make_executor(Some(checker));
2997 let call = make_tool_call(
2998 "Write",
2999 json!({"file_path": path_str, "content": "must not be written"}),
3000 );
3001 let ctx = ToolExecutionContext {
3002 executing_supervisor: None,
3003 session_id: Some("s-hook-explicit-deny"),
3004 root_session_id: None,
3005 tool_call_id: &call.id,
3006 event_tx: None,
3007 available_tool_schemas: None,
3008 bypass_permissions: false,
3009 auto_approve_permissions: false,
3010 plan_read_only: false,
3011 can_async_resume: false,
3012 bash_completion_sink: None,
3013 pre_parsed_args: None,
3014 };
3015
3016 let result = crate::with_hook_permission_override(
3017 Some(crate::HookPermissionOverride::Allow),
3018 &call.id,
3019 executor.execute_with_context(&call, ctx),
3020 )
3021 .await;
3022
3023 assert!(
3024 matches!(result, Err(ToolError::Execution(ref message)) if message.contains("remembered session decision")),
3025 "explicit deny must remain authoritative: {result:?}"
3026 );
3027 assert!(!path.exists());
3028 }
3029
3030 #[tokio::test]
3031 async fn test_forced_ask_rule_overrides_bypass() {
3032 let config = Arc::new(crate::permission::PermissionConfig::new());
3036 let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
3037 let executor = BuiltinToolExecutorBuilder::new()
3038 .with_tool(BashTool::new())
3039 .expect("register Bash tool")
3040 .with_permission_checker(checker)
3041 .build();
3042 let dir = tempfile::tempdir().unwrap();
3043 let denied_path = dir.path().join("forced-denied.txt");
3044 let denied_command = format!("eval 'printf denied > {}'", denied_path.display());
3045 let denied_requests = Arc::new(AtomicUsize::new(0));
3046 let deny_proxy: Arc<dyn crate::approval::ApprovalProxy> =
3047 Arc::new(RecordingApprovalProxy {
3048 requests: denied_requests.clone(),
3049 approve: false,
3050 });
3051
3052 let denied_call = make_tool_call("Bash", json!({"command": denied_command}));
3053 let denied_ctx = ToolExecutionContext {
3054 executing_supervisor: None,
3055 session_id: Some("s-forced"),
3056 root_session_id: None,
3057 tool_call_id: &denied_call.id,
3058 event_tx: None,
3059 available_tool_schemas: None,
3060 bypass_permissions: true,
3061 auto_approve_permissions: false,
3062 plan_read_only: false,
3063 can_async_resume: false,
3064 bash_completion_sink: None,
3065 pre_parsed_args: None,
3066 };
3067 let denied = crate::approval::with_approval_proxy(
3068 Some(deny_proxy),
3069 executor.execute_with_context(&denied_call, denied_ctx),
3070 )
3071 .await;
3072
3073 assert!(
3074 matches!(denied, Err(ToolError::Execution(ref message)) if message.contains("denied by host")),
3075 "parent denial must block forced-ask execution under bypass: {denied:?}"
3076 );
3077 assert_eq!(denied_requests.load(Ordering::SeqCst), 1);
3078 assert!(!denied_path.exists(), "denied command must not execute");
3079
3080 let approved_path = dir.path().join("forced-approved.txt");
3081 let approved_command = format!("eval 'printf approved > {}'", approved_path.display());
3082 let approved_requests = Arc::new(AtomicUsize::new(0));
3083 let approve_proxy: Arc<dyn crate::approval::ApprovalProxy> =
3084 Arc::new(RecordingApprovalProxy {
3085 requests: approved_requests.clone(),
3086 approve: true,
3087 });
3088 let approved_call = make_tool_call("Bash", json!({"command": approved_command}));
3089 let approved_ctx = ToolExecutionContext {
3090 executing_supervisor: None,
3091 session_id: Some("s-forced"),
3092 root_session_id: None,
3093 tool_call_id: &approved_call.id,
3094 event_tx: None,
3095 available_tool_schemas: None,
3096 bypass_permissions: true,
3097 auto_approve_permissions: false,
3098 plan_read_only: false,
3099 can_async_resume: false,
3100 bash_completion_sink: None,
3101 pre_parsed_args: None,
3102 };
3103 let approved = crate::approval::with_approval_proxy(
3104 Some(approve_proxy),
3105 executor.execute_with_context(&approved_call, approved_ctx),
3106 )
3107 .await;
3108
3109 assert!(
3110 approved.is_ok(),
3111 "parent approval must allow forced-ask execution under bypass: {approved:?}"
3112 );
3113 assert_eq!(approved_requests.load(Ordering::SeqCst), 1);
3114 assert_eq!(fs::read_to_string(approved_path).await.unwrap(), "approved");
3115 }
3116
3117 #[tokio::test]
3118 async fn auto_executes_forced_ask_without_proxy_or_human_event() {
3119 let config = Arc::new(crate::permission::PermissionConfig::new());
3120 let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
3121 let executor = BuiltinToolExecutorBuilder::new()
3122 .with_tool(BashTool::new())
3123 .expect("register Bash tool")
3124 .with_permission_checker(checker)
3125 .build();
3126 let dir = tempfile::tempdir().unwrap();
3127 let path = dir.path().join("auto-forced.txt");
3128 let command = format!("eval 'printf auto > {}'", path.display());
3129 let approval_requests = Arc::new(AtomicUsize::new(0));
3130 let proxy: Arc<dyn crate::approval::ApprovalProxy> = Arc::new(RecordingApprovalProxy {
3131 requests: approval_requests.clone(),
3132 approve: false,
3133 });
3134 let (event_tx, mut event_rx) = mpsc::channel(8);
3135 let call = make_tool_call("Bash", json!({"command": command}));
3136 let ctx = ToolExecutionContext {
3137 executing_supervisor: None,
3138 session_id: Some("s-auto"),
3139 root_session_id: None,
3140 tool_call_id: &call.id,
3141 event_tx: Some(&event_tx),
3142 available_tool_schemas: None,
3143 bypass_permissions: false,
3144 auto_approve_permissions: true,
3145 plan_read_only: false,
3146 can_async_resume: false,
3147 bash_completion_sink: None,
3148 pre_parsed_args: None,
3149 };
3150
3151 let result = crate::approval::with_approval_proxy(
3152 Some(proxy),
3153 executor.execute_with_context(&call, ctx),
3154 )
3155 .await;
3156
3157 assert!(result.is_ok(), "Auto should execute directly: {result:?}");
3158 assert_eq!(fs::read_to_string(path).await.unwrap(), "auto");
3159 assert_eq!(approval_requests.load(Ordering::SeqCst), 0);
3160 assert!(
3161 event_rx.try_recv().is_err(),
3162 "Auto must not emit an interactive approval request"
3163 );
3164 }
3165
3166 #[tokio::test]
3167 async fn read_only_child_checker_denies_every_side_effect_under_auto_and_bypass() {
3168 let config = Arc::new(crate::permission::PermissionConfig::new());
3169 config.set_mode(crate::permission::PermissionMode::Auto);
3170 let base: Arc<dyn crate::permission::PermissionChecker> = Arc::new(
3171 crate::permission::ConfigPermissionChecker::new(config.clone()),
3172 );
3173 let checker = Arc::new(crate::permission::ReadOnlyCommandChecker::new(base));
3174 let executor = BuiltinToolExecutorBuilder::new()
3175 .with_tool(BashTool::new())
3176 .expect("register Bash tool")
3177 .with_tool(WriteTool::new())
3178 .expect("register Write tool")
3179 .with_permission_checker(checker)
3180 .build();
3181
3182 for (mode, bypass_permissions, auto_approve_permissions) in
3187 [("auto", false, true), ("bypass", true, false)]
3188 {
3189 for command in ["pwd", "cat Cargo.toml"] {
3190 let call = make_tool_call("Bash", json!({"command": command}));
3191 let session_id = format!("planner-no-shell-{mode}");
3192 let ctx = ToolExecutionContext {
3193 executing_supervisor: None,
3194 session_id: Some(&session_id),
3195 root_session_id: None,
3196 tool_call_id: &call.id,
3197 event_tx: None,
3198 available_tool_schemas: None,
3199 bypass_permissions,
3200 auto_approve_permissions,
3201 plan_read_only: false,
3202 can_async_resume: false,
3203 bash_completion_sink: None,
3204 pre_parsed_args: None,
3205 };
3206 let error = executor
3207 .execute_with_context(&call, ctx)
3208 .await
3209 .expect_err("read-only children must not enter an ambient shell");
3210 assert!(error
3211 .to_string()
3212 .contains("Execute shell commands is disabled"));
3213 }
3214 }
3215
3216 let dir = tempfile::tempdir().unwrap();
3217 let direct_write_path = dir.path().join("planner-direct-write.txt");
3218 for (mode, bypass_permissions, auto_approve_permissions) in
3219 [("auto", false, true), ("bypass", true, false)]
3220 {
3221 let call = make_tool_call(
3222 "Write",
3223 json!({"file_path": direct_write_path, "content": "blocked"}),
3224 );
3225 let session_id = format!("planner-direct-write-{mode}");
3226 let ctx = ToolExecutionContext {
3227 executing_supervisor: None,
3228 session_id: Some(&session_id),
3229 root_session_id: None,
3230 tool_call_id: &call.id,
3231 event_tx: None,
3232 available_tool_schemas: None,
3233 bypass_permissions,
3234 auto_approve_permissions,
3235 plan_read_only: false,
3236 can_async_resume: false,
3237 bash_completion_sink: None,
3238 pre_parsed_args: None,
3239 };
3240 let error = executor
3241 .execute_with_context(&call, ctx)
3242 .await
3243 .expect_err("unadvertised direct writes must remain hard-denied");
3244 assert!(error
3245 .to_string()
3246 .contains("Write files to disk is disabled"));
3247 assert!(!direct_write_path.exists());
3248 }
3249
3250 let path = dir.path().join("planner-mutation.txt");
3251 let command = format!("printf blocked > {}", path.display());
3252 for (session_id, bypass_permissions, auto_approve_permissions) in [
3253 ("planner-auto", false, true),
3254 ("planner-bypass", true, false),
3255 ] {
3256 let call = make_tool_call("Bash", json!({"command": command.clone()}));
3257 let ctx = ToolExecutionContext {
3258 executing_supervisor: None,
3259 session_id: Some(session_id),
3260 root_session_id: None,
3261 tool_call_id: &call.id,
3262 event_tx: None,
3263 available_tool_schemas: None,
3264 bypass_permissions,
3265 auto_approve_permissions,
3266 plan_read_only: false,
3267 can_async_resume: false,
3268 bash_completion_sink: None,
3269 pre_parsed_args: None,
3270 };
3271
3272 let error = executor
3273 .execute_with_context(&call, ctx)
3274 .await
3275 .expect_err("Auto/Bypass must retain read-only child authority");
3276
3277 assert!(error.to_string().contains("Read-only child"));
3278 assert!(!path.exists());
3279 }
3280
3281 let delete_target = dir.path().join("planner-delete-target");
3282 fs::create_dir_all(&delete_target).await.unwrap();
3283 fs::write(delete_target.join("keep.txt"), "keep")
3284 .await
3285 .unwrap();
3286 let delete_command = format!("rm -rf {}", delete_target.display());
3287 for (session_id, bypass_permissions, auto_approve_permissions) in [
3288 ("planner-delete-auto", false, true),
3289 ("planner-delete-bypass", true, false),
3290 ] {
3291 let call = make_tool_call("Bash", json!({"command": delete_command.clone()}));
3292 let ctx = ToolExecutionContext {
3293 executing_supervisor: None,
3294 session_id: Some(session_id),
3295 root_session_id: None,
3296 tool_call_id: &call.id,
3297 event_tx: None,
3298 available_tool_schemas: None,
3299 bypass_permissions,
3300 auto_approve_permissions,
3301 plan_read_only: false,
3302 can_async_resume: false,
3303 bash_completion_sink: None,
3304 pre_parsed_args: None,
3305 };
3306 let error = executor
3307 .execute_with_context(&call, ctx)
3308 .await
3309 .expect_err("delete operations must remain hard-denied");
3310 assert!(error
3311 .to_string()
3312 .contains("Delete files or directories is disabled"));
3313 assert!(delete_target.exists());
3314 }
3315
3316 let git_output = dir.path().join("planner-git-output.txt");
3317 let git_command = format!("git diff --output={}", git_output.display());
3318 for (session_id, bypass_permissions, auto_approve_permissions) in [
3319 ("planner-git-auto", false, true),
3320 ("planner-git-bypass", true, false),
3321 ] {
3322 let call = make_tool_call("Bash", json!({"command": git_command.clone()}));
3323 let ctx = ToolExecutionContext {
3324 executing_supervisor: None,
3325 session_id: Some(session_id),
3326 root_session_id: None,
3327 tool_call_id: &call.id,
3328 event_tx: None,
3329 available_tool_schemas: None,
3330 bypass_permissions,
3331 auto_approve_permissions,
3332 plan_read_only: false,
3333 can_async_resume: false,
3334 bash_completion_sink: None,
3335 pre_parsed_args: None,
3336 };
3337
3338 let error = executor
3339 .execute_with_context(&call, ctx)
3340 .await
3341 .expect_err("git output flags must not bypass read-only child authority");
3342
3343 assert!(error.to_string().contains("Read-only child"));
3344 assert!(!git_output.exists());
3345 }
3346
3347 let find_output = dir.path().join("planner-find-output.txt");
3348 let denied_commands = [
3349 ("cargo", "cargo test --help".to_string(), None),
3350 (
3351 "git-signature-flag",
3352 "git log --no-ext-diff --no-textconv --show-signature -1".to_string(),
3353 None,
3354 ),
3355 (
3356 "git-signature-format",
3357 "git log --no-ext-diff --no-textconv --no-show-signature --format=%G? -1"
3358 .to_string(),
3359 None,
3360 ),
3361 (
3362 "find",
3363 format!(
3364 "find {} -fprint0 {}",
3365 dir.path().display(),
3366 find_output.display()
3367 ),
3368 Some(find_output.as_path()),
3369 ),
3370 ];
3371 for (command_kind, command, output_path) in denied_commands {
3372 for (mode, bypass_permissions, auto_approve_permissions) in
3373 [("auto", false, true), ("bypass", true, false)]
3374 {
3375 let call = make_tool_call("Bash", json!({"command": command.clone()}));
3376 let session_id = format!("planner-{command_kind}-{mode}");
3377 let ctx = ToolExecutionContext {
3378 executing_supervisor: None,
3379 session_id: Some(&session_id),
3380 root_session_id: None,
3381 tool_call_id: &call.id,
3382 event_tx: None,
3383 available_tool_schemas: None,
3384 bypass_permissions,
3385 auto_approve_permissions,
3386 plan_read_only: false,
3387 can_async_resume: false,
3388 bash_completion_sink: None,
3389 pre_parsed_args: None,
3390 };
3391
3392 let error = executor
3393 .execute_with_context(&call, ctx)
3394 .await
3395 .expect_err("executable/write-capable commands must remain denied");
3396
3397 assert!(error.to_string().contains("Read-only child"));
3398 if let Some(path) = output_path {
3399 assert!(!path.exists());
3400 }
3401 }
3402 }
3403
3404 let ansi_find_target = dir.path().join("planner-ansi-find-target");
3408 fs::create_dir_all(&ansi_find_target).await.unwrap();
3409 fs::write(ansi_find_target.join("keep.txt"), "keep")
3410 .await
3411 .unwrap();
3412 let ansi_find_command = format!(r"find {} $'-de'lete", ansi_find_target.display());
3413 for (mode, bypass_permissions, auto_approve_permissions) in
3414 [("auto", false, true), ("bypass", true, false)]
3415 {
3416 let call = make_tool_call("Bash", json!({"command": ansi_find_command.clone()}));
3417 let session_id = format!("planner-find-ansi-{mode}");
3418 let ctx = ToolExecutionContext {
3419 executing_supervisor: None,
3420 session_id: Some(&session_id),
3421 root_session_id: None,
3422 tool_call_id: &call.id,
3423 event_tx: None,
3424 available_tool_schemas: None,
3425 bypass_permissions,
3426 auto_approve_permissions,
3427 plan_read_only: false,
3428 can_async_resume: false,
3429 bash_completion_sink: None,
3430 pre_parsed_args: None,
3431 };
3432
3433 let error = executor
3434 .execute_with_context(&call, ctx)
3435 .await
3436 .expect_err("ANSI-C expansion must remain denied before Bash execution");
3437
3438 assert!(error.to_string().contains("Read-only child"));
3439 assert!(
3440 ansi_find_target.exists(),
3441 "the rejected command must not delete its target"
3442 );
3443 }
3444 }
3445
3446 #[tokio::test]
3447 async fn test_explicit_deny_overrides_bypass() {
3448 let dir = tempfile::tempdir().unwrap();
3449 let path = dir.path().join("explicit-deny.txt");
3450 let path_str = path.to_str().unwrap();
3451 let config = Arc::new(crate::permission::PermissionConfig::new());
3452 config.add_rule(crate::permission::PermissionRule::new(
3453 crate::permission::PermissionType::WriteFile,
3454 path_str,
3455 false,
3456 ));
3457 let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
3458 let executor = make_executor(Some(checker));
3459 let call = make_tool_call(
3460 "Write",
3461 json!({"file_path": path_str, "content": "blocked"}),
3462 );
3463 let ctx = ToolExecutionContext {
3464 executing_supervisor: None,
3465 session_id: Some("s-explicit-deny"),
3466 root_session_id: None,
3467 tool_call_id: &call.id,
3468 event_tx: None,
3469 available_tool_schemas: None,
3470 bypass_permissions: true,
3471 auto_approve_permissions: false,
3472 plan_read_only: false,
3473 can_async_resume: false,
3474 bash_completion_sink: None,
3475 pre_parsed_args: None,
3476 };
3477
3478 let result = executor.execute_with_context(&call, ctx).await;
3479 assert!(
3480 matches!(result, Err(ToolError::Execution(ref message)) if message.contains("explicit policy")),
3481 "explicit deny must beat bypass: {result:?}"
3482 );
3483 assert!(!path.exists());
3484 }
3485
3486 #[tokio::test]
3487 async fn test_explicit_delete_deny_overrides_bypass() {
3488 let config = Arc::new(crate::permission::PermissionConfig::new());
3489 config.add_rule(crate::permission::PermissionRule::new(
3490 crate::permission::PermissionType::DeleteOperation,
3491 "rm child-to-preserve",
3492 false,
3493 ));
3494 let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
3495 let executor = BuiltinToolExecutorBuilder::new()
3496 .with_tool(BashTool::new())
3497 .expect("register Bash tool")
3498 .with_permission_checker(checker)
3499 .build();
3500 let call = make_tool_call("Bash", json!({"command": "rm child-to-preserve"}));
3501 let ctx = ToolExecutionContext {
3502 executing_supervisor: None,
3503 session_id: Some("s-explicit-delete-deny"),
3504 root_session_id: None,
3505 tool_call_id: &call.id,
3506 event_tx: None,
3507 available_tool_schemas: None,
3508 bypass_permissions: true,
3509 auto_approve_permissions: false,
3510 plan_read_only: false,
3511 can_async_resume: false,
3512 bash_completion_sink: None,
3513 pre_parsed_args: None,
3514 };
3515
3516 let result = executor.execute_with_context(&call, ctx).await;
3517 assert!(
3518 matches!(result, Err(ToolError::Execution(ref message)) if message.contains("explicit policy")),
3519 "explicit delete deny must beat bypass: {result:?}"
3520 );
3521 }
3522
3523 #[tokio::test]
3524 async fn terminal_session_tools_preserve_plan_and_read_only_hard_boundaries() {
3525 let readonly = Arc::new(crate::permission::ReadOnlyCommandChecker::new(Arc::new(
3526 crate::permission::AllowAllPermissionChecker,
3527 )));
3528 let restricted = BuiltinToolExecutorBuilder::new()
3529 .with_default_tools()
3530 .with_permission_checker(readonly)
3531 .build();
3532 let ordinary = BuiltinToolExecutor::new();
3533 for (name, args) in [
3534 ("exec_command", json!({"cmd":"cargo test"})),
3535 ("write_stdin", json!({"session_id":"exec", "chars":""})),
3536 ("write_stdin", json!({"session_id":"exec", "chars":"yes\n"})),
3537 ] {
3538 let call = make_tool_call(name, args);
3539 for (bypass, auto) in [(false, false), (true, false), (false, true)] {
3540 let mut ctx = ToolExecutionContext::none(&call.id);
3541 ctx.bypass_permissions = bypass;
3542 ctx.auto_approve_permissions = auto;
3543 let denied = restricted
3544 .check_permissions_for_exact(&call, name, &ctx)
3545 .await;
3546 assert!(
3547 matches!(denied, Err(ToolError::Execution(ref message)) if message.contains("Read-only child")),
3548 "{name}: {denied:?}"
3549 );
3550 ctx.plan_read_only = true;
3551 let denied = ordinary
3552 .check_permissions_for_exact(&call, name, &ctx)
3553 .await;
3554 assert!(
3555 matches!(denied, Err(ToolError::Execution(ref message)) if message.contains("Plan mode")),
3556 "{name}: {denied:?}"
3557 );
3558 }
3559 }
3560 }
3561
3562 #[tokio::test]
3563 async fn exec_command_preserves_explicit_delete_deny_under_auto_and_bypass() {
3564 let config = Arc::new(crate::permission::PermissionConfig::new());
3565 config.add_rule(crate::permission::PermissionRule::new(
3566 crate::permission::PermissionType::DeleteOperation,
3567 "rm child-to-preserve",
3568 false,
3569 ));
3570 let executor = BuiltinToolExecutorBuilder::new()
3571 .with_default_tools()
3572 .with_permission_checker(Arc::new(crate::permission::ConfigPermissionChecker::new(
3573 config,
3574 )))
3575 .build();
3576 let call = make_tool_call("exec_command", json!({"cmd":"rm child-to-preserve"}));
3577 for (bypass, auto) in [(false, false), (true, false), (false, true)] {
3578 let mut ctx = ToolExecutionContext::none(&call.id);
3579 ctx.bypass_permissions = bypass;
3580 ctx.auto_approve_permissions = auto;
3581 let denied = executor
3582 .check_permissions_for_exact(&call, "exec_command", &ctx)
3583 .await;
3584 assert!(
3585 matches!(denied, Err(ToolError::Execution(ref message)) if message.contains("explicit policy")),
3586 "{denied:?}"
3587 );
3588 }
3589 }
3590
3591 #[tokio::test]
3592 async fn plan_auto_denies_mutation_but_allows_read_without_a_checker() {
3593 let executor = BuiltinToolExecutor::new();
3594 let dir = tempfile::tempdir().unwrap();
3595 let path = dir.path().join("plan-auto.txt");
3596 let write = make_tool_call(
3597 "Write",
3598 json!({"file_path": path, "content": "must not run"}),
3599 );
3600 let write_ctx = ToolExecutionContext {
3601 executing_supervisor: None,
3602 session_id: Some("plan-auto"),
3603 root_session_id: None,
3604 tool_call_id: &write.id,
3605 event_tx: None,
3606 available_tool_schemas: None,
3607 bypass_permissions: false,
3608 auto_approve_permissions: true,
3609 plan_read_only: true,
3610 can_async_resume: false,
3611 bash_completion_sink: None,
3612 pre_parsed_args: None,
3613 };
3614 let denied = executor.execute_with_context(&write, write_ctx).await;
3615 assert!(matches!(
3616 denied,
3617 Err(ToolError::Execution(ref message)) if message.contains("Plan mode")
3618 ));
3619 assert!(tokio::fs::metadata(&path).await.is_err());
3620
3621 tokio::fs::write(&path, "readable").await.unwrap();
3622 let read = make_tool_call("Read", json!({"file_path": path}));
3623 let read_ctx = ToolExecutionContext {
3624 executing_supervisor: None,
3625 session_id: Some("plan-auto"),
3626 root_session_id: None,
3627 tool_call_id: &read.id,
3628 event_tx: None,
3629 available_tool_schemas: None,
3630 bypass_permissions: false,
3631 auto_approve_permissions: true,
3632 plan_read_only: true,
3633 can_async_resume: false,
3634 bash_completion_sink: None,
3635 pre_parsed_args: None,
3636 };
3637 let allowed = executor
3638 .execute_with_context(&read, read_ctx)
3639 .await
3640 .unwrap();
3641 assert!(allowed.success);
3642 }
3643
3644 #[tokio::test]
3645 async fn auto_request_permissions_fails_without_creating_a_pause() {
3646 let executor = BuiltinToolExecutor::new();
3647 let (event_tx, mut event_rx) = mpsc::channel(4);
3648 let call = make_tool_call("request_permissions", json!({}));
3649 let ctx = ToolExecutionContext {
3650 executing_supervisor: None,
3651 session_id: Some("auto-no-prompt"),
3652 root_session_id: None,
3653 tool_call_id: &call.id,
3654 event_tx: Some(&event_tx),
3655 available_tool_schemas: None,
3656 bypass_permissions: false,
3657 auto_approve_permissions: true,
3658 plan_read_only: false,
3659 can_async_resume: false,
3660 bash_completion_sink: None,
3661 pre_parsed_args: None,
3662 };
3663
3664 let result = executor.execute_with_context_outcome(&call, ctx).await;
3665 assert!(matches!(
3666 result,
3667 Err(ToolError::Execution(ref message)) if message.contains("cannot request expanded permissions")
3668 ));
3669 assert!(event_rx.try_recv().is_err());
3670 }
3671
3672 #[tokio::test]
3673 async fn interactive_gate_returns_synthesized_approval_pause() {
3674 let config = Arc::new(crate::permission::PermissionConfig::new());
3682 config.set_ask_rules(["Write(/etc/**)".to_string()]);
3683 config.register_session_workspace("s-interactive", "/workspace/project");
3684 let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
3685 let executor = make_executor(Some(checker));
3686
3687 let (tx, mut rx) = mpsc::channel(8);
3688 let call = make_tool_call(
3689 "Write",
3690 json!({"file_path": "/etc/gated.conf", "content": "x"}),
3691 );
3692 let ctx = ToolExecutionContext {
3693 executing_supervisor: None,
3694 session_id: Some("s-interactive"),
3695 root_session_id: None,
3696 tool_call_id: &call.id,
3697 event_tx: Some(&tx),
3698 available_tool_schemas: None,
3699 bypass_permissions: false,
3700 auto_approve_permissions: false,
3701 plan_read_only: false,
3702 can_async_resume: false,
3703 bash_completion_sink: None,
3704 pre_parsed_args: None,
3705 };
3706
3707 let result = executor
3708 .execute_with_context(&call, ctx)
3709 .await
3710 .expect("interactive gate should pause (Ok), not error");
3711
3712 assert_eq!(
3713 result.display_preference.as_deref(),
3714 Some("request_permissions"),
3715 "interactive gate must return the request_permissions pause result"
3716 );
3717 assert!(result.result.contains("awaiting_permission_approval"));
3718 let payload: serde_json::Value = serde_json::from_str(&result.result).expect("payload");
3719 let request = &payload["permission_request"];
3720 assert_eq!(request["request_id"], call.id);
3721 assert_eq!(request["session_id"], "s-interactive");
3722 assert_eq!(request["workspace_path"], "/workspace/project");
3723 assert_eq!(request["reason_code"], "configured_always_ask");
3724 assert_eq!(
3725 request["allowed_decisions"],
3726 json!(["allow_once", "deny_once"])
3727 );
3728 assert_eq!(payload["options"], json!(["Approve", "Deny"]));
3729 assert!(fs::metadata("/etc/gated.conf").await.is_err());
3730
3731 let ev = rx.recv().await.expect("approval event should be emitted");
3732 assert!(
3733 matches!(ev, AgentEvent::ToolApprovalRequested { tool_name, .. } if tool_name == "Write")
3734 );
3735 }
3736
3737 #[tokio::test]
3738 async fn proactive_permission_batch_uses_typed_remembered_scopes_then_completes() {
3739 let config = Arc::new(crate::permission::PermissionConfig::new());
3740 config.set_session_workspace("proactive-session", Some("/workspace/project".to_string()));
3741 let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(
3742 config.clone(),
3743 ));
3744 let executor = BuiltinToolExecutorBuilder::new()
3745 .with_tool(crate::tools::RequestPermissionsTool::new())
3746 .expect("register request_permissions")
3747 .with_permission_checker(checker)
3748 .build();
3749 let call = make_tool_call(
3750 "request_permissions",
3751 json!({
3752 "reason": "Deploy the service",
3753 "permissions": [
3754 {
3755 "type": "execute_command",
3756 "resource": "docker compose up -d"
3757 },
3758 {
3759 "type": "http_request",
3760 "resource": "registry.example.com"
3761 }
3762 ]
3763 }),
3764 );
3765 let (event_tx, _event_rx) = mpsc::channel(8);
3766
3767 let first = executor
3768 .execute_with_context(
3769 &call,
3770 ToolExecutionContext {
3771 executing_supervisor: None,
3772 session_id: Some("proactive-session"),
3773 root_session_id: None,
3774 tool_call_id: &call.id,
3775 event_tx: Some(&event_tx),
3776 available_tool_schemas: None,
3777 bypass_permissions: false,
3778 auto_approve_permissions: false,
3779 plan_read_only: false,
3780 can_async_resume: false,
3781 bash_completion_sink: None,
3782 pre_parsed_args: None,
3783 },
3784 )
3785 .await
3786 .expect("first batch context pauses");
3787 let first_payload: serde_json::Value = serde_json::from_str(&first.result).unwrap();
3788 let first_request = &first_payload["permission_request"];
3789 assert_eq!(first_request["resource"], "docker compose up -d");
3790 assert!(!first_request["allowed_decisions"]
3791 .as_array()
3792 .unwrap()
3793 .contains(&json!("allow_once")));
3794 assert!(first_request["allowed_decisions"]
3795 .as_array()
3796 .unwrap()
3797 .contains(&json!("allow_session")));
3798 let first_matcher: crate::permission::PermissionMatcher =
3799 serde_json::from_value(first_request["suggested_matchers"][0].clone()).unwrap();
3800 config
3801 .grant_typed_scoped_session_permission(
3802 "proactive-session",
3803 crate::permission::PermissionType::ExecuteCommand,
3804 first_matcher,
3805 )
3806 .unwrap();
3807
3808 let second = executor
3809 .execute_with_context(
3810 &call,
3811 ToolExecutionContext {
3812 executing_supervisor: None,
3813 session_id: Some("proactive-session"),
3814 root_session_id: None,
3815 tool_call_id: &call.id,
3816 event_tx: Some(&event_tx),
3817 available_tool_schemas: None,
3818 bypass_permissions: false,
3819 auto_approve_permissions: false,
3820 plan_read_only: false,
3821 can_async_resume: false,
3822 bash_completion_sink: None,
3823 pre_parsed_args: None,
3824 },
3825 )
3826 .await
3827 .expect("second batch context pauses");
3828 let second_payload: serde_json::Value = serde_json::from_str(&second.result).unwrap();
3829 let second_request = &second_payload["permission_request"];
3830 assert_eq!(second_request["resource"], "registry.example.com");
3831 let second_matcher: crate::permission::PermissionMatcher =
3832 serde_json::from_value(second_request["suggested_matchers"][0].clone()).unwrap();
3833 config
3834 .grant_typed_scoped_session_permission(
3835 "proactive-session",
3836 crate::permission::PermissionType::HttpRequest,
3837 second_matcher,
3838 )
3839 .unwrap();
3840
3841 let completed = executor
3842 .execute_with_context(
3843 &call,
3844 ToolExecutionContext {
3845 executing_supervisor: None,
3846 session_id: Some("proactive-session"),
3847 root_session_id: None,
3848 tool_call_id: &call.id,
3849 event_tx: Some(&event_tx),
3850 available_tool_schemas: None,
3851 bypass_permissions: false,
3852 auto_approve_permissions: false,
3853 plan_read_only: false,
3854 can_async_resume: false,
3855 bash_completion_sink: None,
3856 pre_parsed_args: None,
3857 },
3858 )
3859 .await
3860 .expect("all authorized contexts complete the tool");
3861 assert!(completed.display_preference.is_none());
3862 let completed_payload: serde_json::Value = serde_json::from_str(&completed.result).unwrap();
3863 assert_eq!(completed_payload["status"], "permissions_authorized");
3864 assert_eq!(
3865 completed_payload["permissions"].as_array().unwrap().len(),
3866 2
3867 );
3868 }
3869
3870 #[tokio::test]
3871 async fn workspace_permission_scope_uses_only_registered_session_identity() {
3872 let registered = Arc::new(crate::permission::PermissionConfig::new());
3873 registered.register_session_workspace("registered", "/workspace/authoritative");
3874 let registered_executor = make_executor(Some(Arc::new(
3875 crate::permission::ConfigPermissionChecker::new(registered.clone()),
3876 )));
3877
3878 let first = permission_request_payload(
3879 ®istered_executor,
3880 "registered",
3881 json!({
3882 "file_path": "/tmp/first.txt",
3883 "content": "x",
3884 "cwd": "/model/chosen-a",
3885 "workspace_path": "/model/chosen-b"
3886 }),
3887 )
3888 .await;
3889 let second = permission_request_payload(
3890 ®istered_executor,
3891 "registered",
3892 json!({
3893 "file_path": "/tmp/second.txt",
3894 "content": "x",
3895 "cwd": "/model/chosen-c"
3896 }),
3897 )
3898 .await;
3899 for payload in [&first, &second] {
3900 let request = &payload["permission_request"];
3901 assert_eq!(request["workspace_path"], "/workspace/authoritative");
3902 assert!(request["allowed_decisions"]
3903 .as_array()
3904 .unwrap()
3905 .contains(&json!("allow_workspace")));
3906 }
3907
3908 registered.set_session_workspace("registered", None);
3909 let unbound = permission_request_payload(
3910 ®istered_executor,
3911 "registered",
3912 json!({
3913 "file_path": "/tmp/unbound.txt",
3914 "content": "x",
3915 "cwd": "/workspace/authoritative"
3916 }),
3917 )
3918 .await;
3919 assert!(unbound["permission_request"]["workspace_path"].is_null());
3920 assert!(!unbound["permission_request"]["allowed_decisions"]
3921 .as_array()
3922 .unwrap()
3923 .contains(&json!("allow_workspace")));
3924
3925 registered.set_session_workspace("registered", Some("/workspace/rebound".to_string()));
3926 let rebound = permission_request_payload(
3927 ®istered_executor,
3928 "registered",
3929 json!({
3930 "file_path": "/tmp/rebound.txt",
3931 "content": "x",
3932 "workspace_path": "/workspace/authoritative"
3933 }),
3934 )
3935 .await;
3936 assert_eq!(
3937 rebound["permission_request"]["workspace_path"],
3938 "/workspace/rebound"
3939 );
3940
3941 let unregistered = Arc::new(crate::permission::PermissionConfig::new());
3942 let unregistered_executor = make_executor(Some(Arc::new(
3943 crate::permission::ConfigPermissionChecker::new(unregistered),
3944 )));
3945 let payload = permission_request_payload(
3946 &unregistered_executor,
3947 "unregistered",
3948 json!({
3949 "file_path": "/tmp/unregistered.txt",
3950 "content": "x",
3951 "cwd": "/model/chosen",
3952 "workspace_path": "/also/model/chosen"
3953 }),
3954 )
3955 .await;
3956 let request = &payload["permission_request"];
3957 assert!(request["workspace_path"].is_null());
3958 assert!(!request["allowed_decisions"]
3959 .as_array()
3960 .unwrap()
3961 .contains(&json!("allow_workspace")));
3962 }
3963
3964 #[tokio::test]
3965 async fn check_permissions_for_returns_none_when_permitted() {
3966 let executor = make_executor(None);
3969 let call = make_tool_call("Read", json!({"file_path": "/tmp/whatever"}));
3970 let ctx = ToolExecutionContext::none(&call.id);
3971 let decision = executor
3972 .check_permissions_for(&call, &ctx)
3973 .await
3974 .expect("no checker means no gate");
3975 assert!(decision.is_none(), "no checker must yield Ok(None)");
3976 }
3977
3978 struct HostStub {
3981 approve: bool,
3982 }
3983
3984 #[async_trait]
3985 impl crate::approval::ApprovalProxy for HostStub {
3986 async fn request_approval(&self, _ask: crate::approval::ApprovalAsk) -> bool {
3987 self.approve
3988 }
3989 }
3990
3991 #[tokio::test]
3992 async fn approval_proxy_grant_lets_gated_tool_proceed() {
3993 let dir = tempfile::tempdir().unwrap();
3998 let path = dir.path().join("approved.txt");
3999 let path_str = path.to_str().unwrap().to_string();
4000 let config = Arc::new(crate::permission::PermissionConfig::new());
4001 config.set_ask_rules([format!("Write({}/**)", dir.path().to_str().unwrap())]);
4002 let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
4003 let executor = make_executor(Some(checker));
4004
4005 let call = make_tool_call("Write", json!({"file_path": path_str, "content": "ok"}));
4006 let ctx = ToolExecutionContext {
4007 executing_supervisor: None,
4008 session_id: Some("s-worker"),
4009 root_session_id: None,
4010 tool_call_id: &call.id,
4011 event_tx: None,
4012 available_tool_schemas: None,
4013 bypass_permissions: false,
4014 auto_approve_permissions: false,
4015 plan_read_only: false,
4016 can_async_resume: false,
4017 bash_completion_sink: None,
4018 pre_parsed_args: None,
4019 };
4020
4021 let proxy: Arc<dyn crate::approval::ApprovalProxy> = Arc::new(HostStub { approve: true });
4022 let result = crate::approval::with_approval_proxy(
4023 Some(proxy),
4024 executor.execute_with_context(&call, ctx),
4025 )
4026 .await;
4027
4028 assert!(
4029 result.is_ok(),
4030 "host grant should let the write through: {result:?}"
4031 );
4032 assert_eq!(fs::read_to_string(&path).await.unwrap(), "ok");
4033 }
4034
4035 #[tokio::test]
4036 async fn approval_proxy_deny_fails_gated_tool_closed() {
4037 let dir = tempfile::tempdir().unwrap();
4040 let path = dir.path().join("denied.txt");
4041 let path_str = path.to_str().unwrap().to_string();
4042 let config = Arc::new(crate::permission::PermissionConfig::new());
4043 config.set_ask_rules([format!("Write({}/**)", dir.path().to_str().unwrap())]);
4044 let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
4045 let executor = make_executor(Some(checker));
4046
4047 let call = make_tool_call("Write", json!({"file_path": path_str, "content": "nope"}));
4048 let ctx = ToolExecutionContext {
4049 executing_supervisor: None,
4050 session_id: Some("s-worker"),
4051 root_session_id: None,
4052 tool_call_id: &call.id,
4053 event_tx: None,
4054 available_tool_schemas: None,
4055 bypass_permissions: false,
4056 auto_approve_permissions: false,
4057 plan_read_only: false,
4058 can_async_resume: false,
4059 bash_completion_sink: None,
4060 pre_parsed_args: None,
4061 };
4062
4063 let proxy: Arc<dyn crate::approval::ApprovalProxy> = Arc::new(HostStub { approve: false });
4064 let result = crate::approval::with_approval_proxy(
4065 Some(proxy),
4066 executor.execute_with_context(&call, ctx),
4067 )
4068 .await;
4069
4070 assert!(
4071 matches!(result, Err(ToolError::Execution(ref m)) if m.contains("denied by host")),
4072 "host deny should fail the tool closed: {result:?}"
4073 );
4074 assert!(fs::metadata(&path).await.is_err());
4075 }
4076
4077 #[tokio::test]
4078 async fn tool_can_stream_events_via_execute_with_context() {
4079 struct StreamingTool;
4080
4081 #[async_trait]
4082 impl Tool for StreamingTool {
4083 fn name(&self) -> &str {
4084 "streaming_tool"
4085 }
4086
4087 fn description(&self) -> &str {
4088 "streams one token"
4089 }
4090
4091 fn parameters_schema(&self) -> serde_json::Value {
4092 json!({"type":"object","properties":{}})
4093 }
4094
4095 async fn invoke(
4096 &self,
4097 _args: serde_json::Value,
4098 ctx: ToolCtx,
4099 ) -> Result<ToolOutcome, ToolError> {
4100 ctx.emit(AgentEvent::Token {
4101 content: "stream".to_string(),
4102 })
4103 .await;
4104 Ok(ToolOutcome::Completed(ToolResult {
4105 success: true,
4106 result: "ok".to_string(),
4107 display_preference: None,
4108 images: Vec::new(),
4109 }))
4110 }
4111 }
4112
4113 let executor = BuiltinToolExecutor::new();
4114 executor
4115 .register_tool(StreamingTool)
4116 .expect("register streaming tool");
4117
4118 let (tx, mut rx) = mpsc::channel(8);
4119 let call = make_tool_call("streaming_tool", json!({}));
4120
4121 let result = executor
4122 .execute_with_context(
4123 &call,
4124 ToolExecutionContext {
4125 executing_supervisor: None,
4126 session_id: Some("s1"),
4127 root_session_id: None,
4128 tool_call_id: &call.id,
4129 event_tx: Some(&tx),
4130 available_tool_schemas: None,
4131 bypass_permissions: false,
4132 auto_approve_permissions: false,
4133 plan_read_only: false,
4134 can_async_resume: false,
4135 bash_completion_sink: None,
4136 pre_parsed_args: None,
4137 },
4138 )
4139 .await
4140 .expect("execute tool");
4141
4142 assert!(result.success);
4143 assert_eq!(result.result, "ok");
4144
4145 let ev = rx.recv().await.expect("expected streamed event");
4146 assert!(
4147 matches!(ev, AgentEvent::ToolToken { tool_call_id, content } if tool_call_id == "call_1" && content == "stream")
4148 );
4149 }
4150
4151 #[tokio::test]
4152 async fn removed_legacy_tools_return_not_found() {
4153 let executor = BuiltinToolExecutor::new();
4154
4155 for legacy in ["claude_code", "search_in_file", "search_in_project"] {
4156 let call = make_tool_call(legacy, json!({}));
4157 let result = executor.execute(&call).await;
4158 assert!(matches!(result, Err(ToolError::NotFound(_))));
4159 }
4160 }
4161
4162 #[tokio::test]
4163 async fn executor_prefers_exact_tool_name_before_builtin_alias() {
4164 struct CustomSpawnSessionTool;
4165
4166 #[async_trait]
4167 impl Tool for CustomSpawnSessionTool {
4168 fn name(&self) -> &str {
4169 "spawn_session"
4170 }
4171
4172 fn description(&self) -> &str {
4173 "custom tool for regression coverage"
4174 }
4175
4176 fn parameters_schema(&self) -> serde_json::Value {
4177 json!({"type":"object","properties":{}})
4178 }
4179
4180 async fn invoke(
4181 &self,
4182 _args: serde_json::Value,
4183 _ctx: ToolCtx,
4184 ) -> Result<ToolOutcome, ToolError> {
4185 Ok(ToolOutcome::Completed(ToolResult {
4186 success: true,
4187 result: "custom-spawn-session".to_string(),
4188 display_preference: None,
4189 images: Vec::new(),
4190 }))
4191 }
4192 }
4193
4194 let executor = BuiltinToolExecutorBuilder::new()
4195 .with_tool(CustomSpawnSessionTool)
4196 .expect("register custom spawn_session tool")
4197 .build();
4198
4199 let call = make_tool_call("spawn_session", json!({}));
4200 let result = executor.execute(&call).await.expect("execute custom tool");
4201 assert!(result.success);
4202 assert_eq!(result.result, "custom-spawn-session");
4203 }
4204
4205 struct ExactRoutingTool {
4206 name: &'static str,
4207 label: &'static str,
4208 args_sensitive: bool,
4209 }
4210
4211 #[async_trait]
4212 impl Tool for ExactRoutingTool {
4213 fn name(&self) -> &str {
4214 self.name
4215 }
4216
4217 fn description(&self) -> &str {
4218 "exact routing regression tool"
4219 }
4220
4221 fn parameters_schema(&self) -> serde_json::Value {
4222 json!({"type":"object","properties":{}})
4223 }
4224
4225 fn classify(&self, args: &serde_json::Value) -> bamboo_agent_core::ToolClass {
4226 let has_builtin_normalized_arg = ["file_path", "command", "pattern"]
4227 .iter()
4228 .any(|key| args.get(key).is_some());
4229 if self.args_sensitive && !has_builtin_normalized_arg {
4230 bamboo_agent_core::ToolClass::READONLY_PARALLEL
4231 } else {
4232 bamboo_agent_core::ToolClass::MUTATING_SERIAL
4233 }
4234 }
4235
4236 async fn invoke(
4237 &self,
4238 args: serde_json::Value,
4239 _ctx: ToolCtx,
4240 ) -> Result<ToolOutcome, ToolError> {
4241 Ok(ToolOutcome::Completed(ToolResult {
4242 success: true,
4243 result: json!({"label": self.label, "args": args}).to_string(),
4244 display_preference: None,
4245 images: Vec::new(),
4246 }))
4247 }
4248 }
4249
4250 #[tokio::test]
4251 async fn executor_preserves_namespaced_exact_identity_and_unqualified_collision() {
4252 let executor = BuiltinToolExecutorBuilder::new()
4253 .with_tool(ExactRoutingTool {
4254 name: "a::custom_tool",
4255 label: "namespaced",
4256 args_sensitive: false,
4257 })
4258 .expect("register namespaced tool")
4259 .with_tool(ExactRoutingTool {
4260 name: "custom_tool",
4261 label: "unqualified",
4262 args_sensitive: false,
4263 })
4264 .expect("register unqualified tool")
4265 .build();
4266
4267 assert!(executor.owns_exact_tool("a::custom_tool"));
4268 assert!(executor.owns_exact_tool("custom_tool"));
4269 assert!(!executor.owns_exact_tool("A::custom_tool"));
4270 let names: Vec<String> = executor
4271 .list_tools()
4272 .into_iter()
4273 .map(|schema| schema.function.name)
4274 .collect();
4275 assert!(names.contains(&"a::custom_tool".to_string()));
4276 assert!(names.contains(&"custom_tool".to_string()));
4277
4278 let namespaced = executor
4279 .execute(&make_tool_call("a::custom_tool", json!({})))
4280 .await
4281 .expect("execute namespaced exact tool");
4282 let unqualified = executor
4283 .execute(&make_tool_call("custom_tool", json!({})))
4284 .await
4285 .expect("execute unqualified exact tool");
4286 assert_eq!(
4287 serde_json::from_str::<serde_json::Value>(&namespaced.result).unwrap()["label"],
4288 "namespaced"
4289 );
4290 assert_eq!(
4291 serde_json::from_str::<serde_json::Value>(&unqualified.result).unwrap()["label"],
4292 "unqualified"
4293 );
4294 }
4295
4296 #[tokio::test]
4297 async fn exact_canonical_shadows_do_not_inherit_builtin_argument_provenance() {
4298 let executor = BuiltinToolExecutorBuilder::new()
4299 .with_tool(ExactRoutingTool {
4300 name: "Read",
4301 label: "exact-read",
4302 args_sensitive: true,
4303 })
4304 .expect("register exact Read shadow")
4305 .with_tool(ExactRoutingTool {
4306 name: "Write",
4307 label: "exact-write",
4308 args_sensitive: true,
4309 })
4310 .expect("register exact Write shadow")
4311 .with_tool(ExactRoutingTool {
4312 name: "Edit",
4313 label: "exact-edit",
4314 args_sensitive: true,
4315 })
4316 .expect("register exact Edit shadow")
4317 .with_tool(ExactRoutingTool {
4318 name: "Bash",
4319 label: "exact-bash",
4320 args_sensitive: true,
4321 })
4322 .expect("register exact Bash shadow")
4323 .with_tool(ExactRoutingTool {
4324 name: "Glob",
4325 label: "exact-glob",
4326 args_sensitive: true,
4327 })
4328 .expect("register exact Glob shadow")
4329 .with_default_tools()
4330 .build();
4331
4332 let cases = [
4333 ("Read", json!({"path": "/tmp/custom-read"}), "file_path"),
4334 ("Write", json!({"path": "/tmp/custom-write"}), "file_path"),
4335 ("Edit", json!({"path": "/tmp/custom-edit"}), "file_path"),
4336 ("Bash", json!({"cmd": "custom-command"}), "command"),
4337 (
4338 "Glob",
4339 json!({"path": "/tmp/custom-glob", "recursive": true}),
4340 "pattern",
4341 ),
4342 ];
4343
4344 for (name, args, normalized_key) in cases {
4345 let call = make_tool_call(name, args.clone());
4346 assert_eq!(
4347 executor.call_mutability(&call),
4348 crate::ToolMutability::ReadOnly,
4349 "custom {name} classification must see the original args"
4350 );
4351 assert!(
4352 executor.call_concurrency_safe(&call),
4353 "custom {name} classification must remain parallel-safe"
4354 );
4355
4356 let result = executor
4357 .execute(&call)
4358 .await
4359 .unwrap_or_else(|error| panic!("execute custom {name}: {error}"));
4360 let result: serde_json::Value = serde_json::from_str(&result.result).unwrap();
4361 assert_eq!(result["args"], args, "custom {name} args changed");
4362 assert!(result["args"].get(normalized_key).is_none());
4363 }
4364
4365 let permission_executor = BuiltinToolExecutorBuilder::new()
4370 .with_tool(ExactRoutingTool {
4371 name: "Read",
4372 label: "permission-read",
4373 args_sensitive: true,
4374 })
4375 .expect("register permission-aware Read shadow")
4376 .with_tool(ExactRoutingTool {
4377 name: "Glob",
4378 label: "permission-glob",
4379 args_sensitive: true,
4380 })
4381 .expect("register permission-aware Glob shadow")
4382 .with_default_tools()
4383 .with_permission_checker(Arc::new(crate::permission::AllowAllPermissionChecker))
4384 .build();
4385 for (name, args) in [
4386 ("Read", json!({"path": "/tmp/permission-read"})),
4387 (
4388 "Glob",
4389 json!({"path": "/tmp/permission-glob", "recursive": true}),
4390 ),
4391 ] {
4392 let call = make_tool_call(name, args.clone());
4393 let ctx = ToolExecutionContext::none(&call.id);
4394 assert!(permission_executor
4395 .check_permissions_for(&call, &ctx)
4396 .await
4397 .expect("permission check")
4398 .is_none());
4399 assert_eq!(
4400 permission_executor.call_mutability(&call),
4401 crate::ToolMutability::ReadOnly
4402 );
4403 assert!(permission_executor.call_concurrency_safe(&call));
4404 let result = permission_executor.execute(&call).await.unwrap();
4405 let result: serde_json::Value = serde_json::from_str(&result.result).unwrap();
4406 assert_eq!(result["args"], args);
4407 }
4408 }
4409
4410 #[tokio::test]
4411 async fn exact_apply_patch_keeps_original_args_and_classification() {
4412 let executor = BuiltinToolExecutorBuilder::new()
4413 .with_filesystem_tool("Edit")
4414 .expect("register builtin Edit")
4415 .with_tool(ExactRoutingTool {
4416 name: "apply_patch",
4417 label: "exact-apply-patch",
4418 args_sensitive: true,
4419 })
4420 .expect("register exact apply_patch shadow")
4421 .build();
4422 let call = make_tool_call("apply_patch", json!({"path": "/tmp/exact-shadow"}));
4423
4424 let (mutability, parallel_safe) = executor.call_parallel_classification(&call);
4425 assert_eq!(mutability, crate::ToolMutability::ReadOnly);
4426 assert!(parallel_safe);
4427
4428 let result = executor.execute(&call).await.expect("execute exact shadow");
4429 let result: serde_json::Value = serde_json::from_str(&result.result).unwrap();
4430 assert_eq!(result["label"], "exact-apply-patch");
4431 assert_eq!(result["args"]["path"], "/tmp/exact-shadow");
4432 assert!(result["args"].get("file_path").is_none());
4433 }
4434
4435 #[tokio::test]
4436 async fn exact_permission_seam_preserves_default_apply_patch_builtin_provenance() {
4437 let executor = BuiltinToolExecutorBuilder::new()
4438 .with_filesystem_tool("Edit")
4439 .expect("register builtin Edit")
4440 .with_permission_checker(Arc::new(crate::permission::AllowAllPermissionChecker))
4441 .build();
4442 let raw_args = json!({
4443 "path": "/tmp/exact-permission-apply-patch.txt",
4444 "old_string": "before",
4445 "new_string": "after"
4446 });
4447 let call = make_tool_call("default::apply_patch", raw_args.clone());
4448 let ctx = ToolExecutionContext {
4449 pre_parsed_args: Some(&raw_args),
4450 ..ToolExecutionContext::none(&call.id)
4451 };
4452
4453 assert!(executor
4454 .check_permissions_for_exact(&call, "Edit", &ctx)
4455 .await
4456 .expect("normalized builtin permission check")
4457 .is_none());
4458 assert_eq!(call.function.name, "default::apply_patch");
4459 assert_eq!(
4460 serde_json::from_str::<serde_json::Value>(&call.function.arguments).unwrap(),
4461 raw_args
4462 );
4463 }
4464
4465 #[tokio::test]
4466 async fn unshadowed_alias_and_namespace_keep_legacy_argument_compatibility() {
4467 let dir = tempfile::tempdir().unwrap();
4468 let path = dir.path().join("legacy-alias.txt");
4469 fs::write(&path, "before").await.unwrap();
4470 let executor = BuiltinToolExecutorBuilder::new()
4471 .with_filesystem_tool("Edit")
4472 .expect("register builtin Edit")
4473 .with_permission_checker(Arc::new(crate::permission::AllowAllPermissionChecker))
4474 .build();
4475
4476 let result = executor
4477 .execute(&make_tool_call(
4478 "default::apply_patch",
4479 json!({
4480 "path": path,
4481 "old_string": "before",
4482 "new_string": "after"
4483 }),
4484 ))
4485 .await
4486 .expect("execute unshadowed alias");
4487 assert!(result.success);
4488 assert_eq!(fs::read_to_string(path).await.unwrap(), "after");
4489 }
4490
4491 struct EchoArgsTool;
4496
4497 #[async_trait]
4498 impl Tool for EchoArgsTool {
4499 fn name(&self) -> &str {
4500 "echo_args"
4501 }
4502 fn description(&self) -> &str {
4503 "echoes the `v` arg"
4504 }
4505 fn parameters_schema(&self) -> serde_json::Value {
4506 json!({"type":"object","properties":{"v":{"type":"string"}}})
4507 }
4508 async fn invoke(
4509 &self,
4510 args: serde_json::Value,
4511 _ctx: ToolCtx,
4512 ) -> Result<ToolOutcome, ToolError> {
4513 let v = args
4514 .get("v")
4515 .and_then(serde_json::Value::as_str)
4516 .unwrap_or("<none>")
4517 .to_string();
4518 Ok(ToolOutcome::Completed(ToolResult {
4519 success: true,
4520 result: v,
4521 display_preference: None,
4522 images: Vec::new(),
4523 }))
4524 }
4525 }
4526
4527 fn ctx_with_pre_parsed<'a>(
4528 call_id: &'a str,
4529 pre_parsed: Option<&'a serde_json::Value>,
4530 ) -> ToolExecutionContext<'a> {
4531 ToolExecutionContext {
4532 executing_supervisor: None,
4533 session_id: Some("s-106"),
4534 root_session_id: None,
4535 tool_call_id: call_id,
4536 event_tx: None,
4537 available_tool_schemas: None,
4538 bypass_permissions: false,
4539 auto_approve_permissions: false,
4540 plan_read_only: false,
4541 can_async_resume: false,
4542 bash_completion_sink: None,
4543 pre_parsed_args: pre_parsed,
4544 }
4545 }
4546
4547 #[tokio::test]
4548 async fn execute_with_context_reuses_pre_parsed_args_without_reparsing() {
4549 let executor = BuiltinToolExecutor::new();
4555 executor.register_tool(EchoArgsTool).expect("register echo");
4556
4557 let call = make_tool_call("echo_args", json!({"v": "raw"}));
4558 let pre_parsed = json!({"v": "preparsed"});
4559 let ctx = ctx_with_pre_parsed(&call.id, Some(&pre_parsed));
4560
4561 let result = executor
4562 .execute_with_context(&call, ctx)
4563 .await
4564 .expect("execute echo tool");
4565 assert_eq!(
4566 result.result, "preparsed",
4567 "executor must reuse pre_parsed_args, not re-parse the raw string"
4568 );
4569 }
4570
4571 #[tokio::test]
4572 async fn execute_with_context_parses_raw_when_no_pre_parsed_args() {
4573 let executor = BuiltinToolExecutor::new();
4577 executor.register_tool(EchoArgsTool).expect("register echo");
4578
4579 let call = make_tool_call("echo_args", json!({"v": "raw"}));
4580 let ctx = ctx_with_pre_parsed(&call.id, None);
4581
4582 let result = executor
4583 .execute_with_context(&call, ctx)
4584 .await
4585 .expect("execute echo tool");
4586 assert_eq!(
4587 result.result, "raw",
4588 "without pre_parsed_args the executor parses the raw string as before"
4589 );
4590 }
4591
4592 #[tokio::test]
4593 async fn execute_with_context_malformed_args_repair_unchanged_without_pre_parsed() {
4594 let dir = tempfile::tempdir().unwrap();
4598 let path = dir.path().join("recovered-no-preparsed.txt");
4599 let malformed_args = format!(
4600 r#"{{"file_path":"{}","content":"recovered content""#,
4601 path.display()
4602 );
4603
4604 let executor = BuiltinToolExecutor::new();
4605 let call = make_tool_call_with_raw_args("Write", &malformed_args);
4606 let ctx = ctx_with_pre_parsed(&call.id, None);
4607
4608 let result = executor
4609 .execute_with_context(&call, ctx)
4610 .await
4611 .expect("truncated JSON should be auto-repaired");
4612 assert!(result.success);
4613 let written = fs::read_to_string(&path).await.expect("file written");
4614 assert_eq!(written, "recovered content");
4615 }
4616
4617 #[tokio::test]
4618 async fn successful_write_emits_one_bounded_file_changed_event() {
4619 let dir = tempfile::tempdir().unwrap();
4620 let path = dir.path().join("write-event.txt");
4621 let path_string = path.to_string_lossy().into_owned();
4622 let padded_path = format!(" {path_string} ");
4623 let recorder = Arc::new(InMemoryToolEventRecorder::new(4).unwrap());
4624 let executor = BuiltinToolExecutorBuilder::new()
4625 .with_filesystem_tool("Write")
4626 .unwrap()
4627 .with_tool_event_publisher(recorder.clone())
4628 .build();
4629 let call = make_tool_call_with_id(
4630 "write-call",
4631 "Write",
4632 json!({"file_path": padded_path, "content": "written"}),
4633 );
4634
4635 let result = executor
4636 .execute_with_context(
4637 &call,
4638 tool_event_context(&call, Some("write-session"), Some("write-root-session")),
4639 )
4640 .await
4641 .unwrap();
4642
4643 assert!(result.success);
4644 assert_eq!(fs::read_to_string(path).await.unwrap(), "written");
4645 assert_single_file_changed(
4646 &recorder,
4647 "write-session",
4648 "write-root-session",
4649 "Write",
4650 "write-call",
4651 &path_string,
4652 );
4653 }
4654
4655 #[tokio::test]
4656 async fn successful_edit_emits_one_bounded_file_changed_event() {
4657 let dir = tempfile::tempdir().unwrap();
4658 let path = dir.path().join("edit-event.txt");
4659 fs::write(&path, "before\n").await.unwrap();
4660 let path_string = path.to_string_lossy().into_owned();
4661 let padded_path = format!(" {path_string} ");
4662 let recorder = Arc::new(InMemoryToolEventRecorder::new(4).unwrap());
4663 let executor = BuiltinToolExecutor::new().with_tool_event_publisher(recorder.clone());
4664 let read =
4665 make_tool_call_with_id("edit-read-call", "Read", json!({"file_path": padded_path}));
4666 executor
4667 .execute_with_context(
4668 &read,
4669 tool_event_context(&read, Some("edit-session"), Some("edit-root-session")),
4670 )
4671 .await
4672 .unwrap();
4673 assert!(recorder.try_snapshot().unwrap().is_empty());
4674
4675 let edit = make_tool_call_with_id(
4676 "edit-call",
4677 "Edit",
4678 json!({
4679 "file_path": format!(" {path_string} "),
4680 "old_string": "before",
4681 "new_string": "after"
4682 }),
4683 );
4684 let result = executor
4685 .execute_with_context(
4686 &edit,
4687 tool_event_context(&edit, Some("edit-session"), Some("edit-root-session")),
4688 )
4689 .await
4690 .unwrap();
4691
4692 assert!(result.success);
4693 assert_eq!(fs::read_to_string(path).await.unwrap(), "after\n");
4694 assert_single_file_changed(
4695 &recorder,
4696 "edit-session",
4697 "edit-root-session",
4698 "Edit",
4699 "edit-call",
4700 &path_string,
4701 );
4702 }
4703
4704 #[tokio::test]
4705 async fn successful_apply_patch_alias_emits_canonical_edit_with_original_call_id() {
4706 let dir = tempfile::tempdir().unwrap();
4707 let path = dir.path().join("apply-patch-event.txt");
4708 fs::write(&path, "alpha\nbeta\n").await.unwrap();
4709 let path_string = path.to_string_lossy().into_owned();
4710 let recorder = Arc::new(InMemoryToolEventRecorder::new(4).unwrap());
4711 let executor = BuiltinToolExecutor::new().with_tool_event_publisher(recorder.clone());
4712 let read = make_tool_call_with_id(
4713 "apply-patch-read-call",
4714 "Read",
4715 json!({"file_path": path_string}),
4716 );
4717 executor
4718 .execute_with_context(
4719 &read,
4720 tool_event_context(&read, Some("alias-session"), Some("alias-root-session")),
4721 )
4722 .await
4723 .unwrap();
4724
4725 let edit = make_tool_call_with_id(
4726 "model-original-alias-call",
4727 "apply_patch",
4728 json!({
4729 "path": format!(" {path_string} "),
4730 "old_string": "beta",
4731 "new_string": "BETA"
4732 }),
4733 );
4734 let result = executor
4735 .execute_with_context(
4736 &edit,
4737 tool_event_context(&edit, Some("alias-session"), Some("alias-root-session")),
4738 )
4739 .await
4740 .unwrap();
4741
4742 assert!(result.success);
4743 assert_eq!(fs::read_to_string(path).await.unwrap(), "alpha\nBETA\n");
4744 assert_single_file_changed(
4745 &recorder,
4746 "alias-session",
4747 "alias-root-session",
4748 "Edit",
4749 "model-original-alias-call",
4750 &path_string,
4751 );
4752 }
4753
4754 #[cfg(unix)]
4755 #[tokio::test]
4756 async fn write_through_intermediate_symlink_fails_and_emits_zero_events() {
4757 use std::os::unix::fs::symlink;
4758
4759 let workspace = tempfile::tempdir().unwrap();
4760 let external = tempfile::tempdir().unwrap();
4761 let linked_dir = workspace.path().join("linked");
4762 symlink(external.path(), &linked_dir).unwrap();
4763 let target = linked_dir.join("write.txt");
4764 let recorder = Arc::new(InMemoryToolEventRecorder::new(4).unwrap());
4765 let executor = BuiltinToolExecutorBuilder::new()
4766 .with_filesystem_tool("Write")
4767 .unwrap()
4768 .with_tool_event_publisher(recorder.clone())
4769 .build();
4770 let call = make_tool_call_with_id(
4771 "symlink-write",
4772 "Write",
4773 json!({"file_path": target, "content": "must-not-write"}),
4774 );
4775
4776 let result = executor
4777 .execute_with_context(
4778 &call,
4779 tool_event_context(&call, Some("symlink-session"), Some("symlink-root")),
4780 )
4781 .await;
4782 assert!(
4783 result.is_err() || result.as_ref().is_ok_and(|result| !result.success),
4784 "Write must fail closed through an intermediate symlink"
4785 );
4786 assert!(!external.path().join("write.txt").exists());
4787 assert!(recorder.try_snapshot().unwrap().is_empty());
4788 }
4789
4790 #[cfg(unix)]
4791 #[tokio::test]
4792 async fn edit_of_symlinked_file_fails_and_emits_zero_events() {
4793 use std::os::unix::fs::symlink;
4794
4795 let dir = tempfile::tempdir().unwrap();
4796 let real = dir.path().join("real.txt");
4797 let linked = dir.path().join("linked.txt");
4798 fs::write(&real, "before\n").await.unwrap();
4799 symlink(&real, &linked).unwrap();
4800 let recorder = Arc::new(InMemoryToolEventRecorder::new(4).unwrap());
4801 let executor = BuiltinToolExecutor::new().with_tool_event_publisher(recorder.clone());
4802 let read =
4803 make_tool_call_with_id("symlink-edit-read", "Read", json!({"file_path": linked}));
4804 let _ = executor
4805 .execute_with_context(
4806 &read,
4807 tool_event_context(&read, Some("symlink-session"), Some("symlink-root")),
4808 )
4809 .await;
4810 let edit = make_tool_call_with_id(
4811 "symlink-edit",
4812 "Edit",
4813 json!({
4814 "file_path": linked,
4815 "old_string": "before",
4816 "new_string": "after"
4817 }),
4818 );
4819
4820 let result = executor
4821 .execute_with_context(
4822 &edit,
4823 tool_event_context(&edit, Some("symlink-session"), Some("symlink-root")),
4824 )
4825 .await;
4826 assert!(
4827 result.is_err() || result.as_ref().is_ok_and(|result| !result.success),
4828 "Edit must fail closed for a symlinked final file"
4829 );
4830 assert_eq!(fs::read_to_string(&real).await.unwrap(), "before\n");
4831 assert!(recorder.try_snapshot().unwrap().is_empty());
4832 }
4833
4834 #[tokio::test]
4835 async fn failed_and_non_successful_mutations_emit_no_event() {
4836 let recorder = Arc::new(InMemoryToolEventRecorder::new(4).unwrap());
4837 let executor = BuiltinToolExecutorBuilder::new()
4838 .with_filesystem_tool("Write")
4839 .unwrap()
4840 .with_tool_event_publisher(recorder.clone())
4841 .build();
4842 let failed = make_tool_call_with_id(
4843 "failed-write-call",
4844 "Write",
4845 json!({"file_path": "relative.txt", "content": "never"}),
4846 );
4847 assert!(executor
4848 .execute_with_context(
4849 &failed,
4850 tool_event_context(
4851 &failed,
4852 Some("failure-session"),
4853 Some("failure-root-session"),
4854 ),
4855 )
4856 .await
4857 .is_err());
4858 assert!(recorder.try_snapshot().unwrap().is_empty());
4859
4860 let completed_false = marked_stub_write_executor(false, recorder.clone());
4861 let call = make_tool_call_with_id(
4862 "completed-false-call",
4863 "Write",
4864 json!({"file_path": "/valid/event/path.txt"}),
4865 );
4866 let result = completed_false
4867 .execute_with_context(
4868 &call,
4869 tool_event_context(&call, Some("failure-session"), Some("failure-root-session")),
4870 )
4871 .await
4872 .unwrap();
4873 assert!(!result.success);
4874 assert!(recorder.try_snapshot().unwrap().is_empty());
4875 }
4876
4877 #[tokio::test]
4878 async fn committed_postverify_failure_emits_no_tool_event() {
4879 let dir = tempfile::tempdir().unwrap();
4880 let path = dir.path().join("postverify-conflict.txt");
4881 fs::write(&path, "before").await.unwrap();
4882 let path_string = path.to_string_lossy().into_owned();
4883 let session_id = format!("event-conflict-{}", uuid::Uuid::new_v4());
4884 let recorder = Arc::new(InMemoryToolEventRecorder::new(4).unwrap());
4885 let executor =
4886 Arc::new(BuiltinToolExecutor::new().with_tool_event_publisher(recorder.clone()));
4887
4888 let initial_read = make_tool_call_with_id(
4889 "conflict-initial-read",
4890 "Read",
4891 json!({"file_path": path_string}),
4892 );
4893 executor
4894 .execute_with_context(
4895 &initial_read,
4896 tool_event_context(
4897 &initial_read,
4898 Some(&session_id),
4899 Some("conflict-root-session"),
4900 ),
4901 )
4902 .await
4903 .unwrap();
4904 let (advance_reached, resume_advance) =
4905 crate::tools::read_tracker::pause_next_advance_for_test(&session_id, &path_string)
4906 .await;
4907
4908 let writer_executor = executor.clone();
4909 let writer_session = session_id.clone();
4910 let writer_path = path_string.clone();
4911 let writer = tokio::spawn(async move {
4912 let call = make_tool_call_with_id(
4913 "conflict-write-call",
4914 "Write",
4915 json!({"file_path": writer_path, "content": "intended"}),
4916 );
4917 writer_executor
4918 .execute_with_context(
4919 &call,
4920 tool_event_context(&call, Some(&writer_session), Some("conflict-root-session")),
4921 )
4922 .await
4923 });
4924
4925 tokio::time::timeout(
4926 std::time::Duration::from_secs(5),
4927 advance_reached.notified(),
4928 )
4929 .await
4930 .expect("Write did not reach post-write baseline advancement");
4931 fs::write(&path, "other").await.unwrap();
4932 let concurrent_read = make_tool_call_with_id(
4933 "conflict-concurrent-read",
4934 "Read",
4935 json!({"file_path": path_string}),
4936 );
4937 executor
4938 .execute_with_context(
4939 &concurrent_read,
4940 tool_event_context(
4941 &concurrent_read,
4942 Some(&session_id),
4943 Some("conflict-root-session"),
4944 ),
4945 )
4946 .await
4947 .unwrap();
4948 fs::write(&path, "intended").await.unwrap();
4949 resume_advance.notify_one();
4950
4951 let outcome = tokio::time::timeout(std::time::Duration::from_secs(5), writer)
4952 .await
4953 .expect("Write did not resume")
4954 .unwrap();
4955 assert!(
4956 matches!(outcome, Err(ToolError::Execution(ref message)) if message.contains("Write committed")),
4957 "committed postverify conflict must stay an error: {outcome:?}"
4958 );
4959 assert_eq!(fs::read_to_string(path).await.unwrap(), "intended");
4960 assert!(
4961 recorder.try_snapshot().unwrap().is_empty(),
4962 "an on-disk mutation is not a successful tool outcome"
4963 );
4964 }
4965
4966 #[tokio::test]
4967 async fn permission_pause_does_not_publish_a_success_event() {
4968 let dir = tempfile::tempdir().unwrap();
4969 let path = dir.path().join("approval-gated.txt");
4970 let config = Arc::new(crate::permission::PermissionConfig::new());
4971 config.set_ask_rules([format!("Write({}/**)", dir.path().display())]);
4972 config.register_session_workspace(
4973 "approval-session",
4974 dir.path().to_string_lossy().into_owned(),
4975 );
4976 let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
4977 let recorder = Arc::new(InMemoryToolEventRecorder::new(4).unwrap());
4978 let executor = BuiltinToolExecutorBuilder::new()
4979 .with_filesystem_tool("Write")
4980 .unwrap()
4981 .with_permission_checker(checker)
4982 .with_tool_event_publisher(recorder.clone())
4983 .build();
4984 let call = make_tool_call_with_id(
4985 "approval-call",
4986 "Write",
4987 json!({"file_path": path, "content": "not-yet"}),
4988 );
4989 let (event_tx, _event_rx) = mpsc::channel(4);
4990 let mut ctx = tool_event_context(
4991 &call,
4992 Some("approval-session"),
4993 Some("approval-root-session"),
4994 );
4995 ctx.event_tx = Some(&event_tx);
4996
4997 let result = executor.execute_with_context(&call, ctx).await.unwrap();
4998 assert!(
4999 result.success,
5000 "approval pause is a synthetic success result"
5001 );
5002 assert_eq!(
5003 result.display_preference.as_deref(),
5004 Some("request_permissions")
5005 );
5006 assert!(!path.exists(), "permission pause must not invoke Write");
5007 assert!(recorder.try_snapshot().unwrap().is_empty());
5008 }
5009
5010 #[tokio::test]
5011 async fn missing_authority_or_oversize_path_fails_closed_without_event() {
5012 let recorder = Arc::new(InMemoryToolEventRecorder::new(4).unwrap());
5013 let executor = marked_stub_write_executor(true, recorder.clone());
5014
5015 let missing_session = make_tool_call_with_id(
5016 "missing-session-call",
5017 "Write",
5018 json!({"file_path": "/bounded/path.txt"}),
5019 );
5020 assert!(
5021 executor
5022 .execute_with_context(
5023 &missing_session,
5024 tool_event_context(&missing_session, None, Some("authority-root-session"),),
5025 )
5026 .await
5027 .unwrap()
5028 .success
5029 );
5030
5031 let missing_root = make_tool_call_with_id(
5032 "missing-root-call",
5033 "Write",
5034 json!({"file_path": "/bounded/path.txt"}),
5035 );
5036 assert!(
5037 executor
5038 .execute_with_context(
5039 &missing_root,
5040 tool_event_context(&missing_root, Some("authority-session"), None),
5041 )
5042 .await
5043 .unwrap()
5044 .success
5045 );
5046
5047 let oversize_path = make_tool_call_with_id(
5048 "oversize-path-call",
5049 "Write",
5050 json!({"file_path": "x".repeat(MAX_TOOL_EVENT_PATH_BYTES + 1)}),
5051 );
5052 assert!(
5053 executor
5054 .execute_with_context(
5055 &oversize_path,
5056 tool_event_context(
5057 &oversize_path,
5058 Some("authority-session"),
5059 Some("authority-root-session"),
5060 ),
5061 )
5062 .await
5063 .unwrap()
5064 .success
5065 );
5066
5067 assert!(recorder.try_snapshot().unwrap().is_empty());
5068 }
5069
5070 #[tokio::test]
5071 async fn custom_write_name_never_acquires_builtin_event_provenance() {
5072 let recorder = Arc::new(InMemoryToolEventRecorder::new(4).unwrap());
5073 let registry = ToolRegistry::new();
5074 registry.register(StubWriteTool { success: true }).unwrap();
5075 let from_registry = BuiltinToolExecutor::with_registry(registry)
5076 .with_tool_event_publisher(recorder.clone());
5077 let first = make_tool_call_with_id(
5078 "spoof-registry-call",
5079 "Write",
5080 json!({"file_path": "/spoof/path.txt"}),
5081 );
5082 assert!(
5083 from_registry
5084 .execute_with_context(
5085 &first,
5086 tool_event_context(&first, Some("spoof-session"), Some("spoof-root-session"),),
5087 )
5088 .await
5089 .unwrap()
5090 .success
5091 );
5092
5093 let custom_before_defaults = BuiltinToolExecutorBuilder::new()
5094 .with_tool(StubWriteTool { success: true })
5095 .unwrap()
5096 .with_default_tools()
5097 .with_tool_event_publisher(recorder.clone())
5098 .build();
5099 let second = make_tool_call_with_id(
5100 "spoof-builder-order-call",
5101 "Write",
5102 json!({"file_path": "/spoof/path.txt"}),
5103 );
5104 assert!(
5105 custom_before_defaults
5106 .execute_with_context(
5107 &second,
5108 tool_event_context(&second, Some("spoof-session"), Some("spoof-root-session"),),
5109 )
5110 .await
5111 .unwrap()
5112 .success
5113 );
5114
5115 let replaced_builtin =
5116 BuiltinToolExecutor::new().with_tool_event_publisher(recorder.clone());
5117 assert!(replaced_builtin.registry().unregister("Write"));
5118 replaced_builtin
5119 .register_tool(StubWriteTool { success: true })
5120 .unwrap();
5121 let third = make_tool_call_with_id(
5122 "spoof-replaced-builtin-call",
5123 "Write",
5124 json!({"file_path": "/spoof/path.txt"}),
5125 );
5126 assert!(
5127 replaced_builtin
5128 .execute_with_context(
5129 &third,
5130 tool_event_context(&third, Some("spoof-session"), Some("spoof-root-session"),),
5131 )
5132 .await
5133 .unwrap()
5134 .success
5135 );
5136
5137 assert!(recorder.try_snapshot().unwrap().is_empty());
5138 }
5139
5140 #[tokio::test]
5141 async fn publisher_rejection_or_panic_never_changes_successful_tool_result() {
5142 let full = Arc::new(InMemoryToolEventRecorder::new(1).unwrap());
5143 full.try_publish(seed_event("seed-full")).unwrap();
5144 assert_real_write_succeeds_with_publisher(full.clone(), "full").await;
5145 let retained = full.try_snapshot().unwrap();
5146 assert_eq!(retained.len(), 1);
5147 assert_eq!(retained[0].context.tool_call_id, "seed-full");
5148
5149 let publishers: Vec<(&str, Arc<dyn ToolEventPublisher>)> = vec![
5150 (
5151 "busy",
5152 Arc::new(ReturningPublisher(ToolEventPublishError::Busy)),
5153 ),
5154 (
5155 "poisoned",
5156 Arc::new(ReturningPublisher(ToolEventPublishError::Poisoned)),
5157 ),
5158 (
5159 "failed",
5160 Arc::new(ReturningPublisher(ToolEventPublishError::Failed(
5161 "sink unavailable".to_string(),
5162 ))),
5163 ),
5164 ("enabled-panic", Arc::new(IsEnabledPanicPublisher)),
5165 ("publish-panic", Arc::new(TryPublishPanicPublisher)),
5166 ];
5167 for (label, publisher) in publishers {
5168 assert_real_write_succeeds_with_publisher(publisher, label).await;
5169 }
5170 }
5171
5172 #[tokio::test]
5173 async fn native_ceiling_covers_all_seven_entrypoints_and_sync_surfaces() {
5174 let dir = tempfile::tempdir().unwrap();
5175 let path = dir.path().join("denied.txt");
5176 let executor = BuiltinToolExecutor::new()
5177 .with_native_tool_ceiling(vec!["Glob".into(), "Read".into()])
5178 .unwrap();
5179 let call = make_tool_call("Write", json!({"file_path":path,"content":"forbidden"}));
5180 let ctx = ToolExecutionContext::none(&call.id);
5181 let args = json!({"file_path":path,"content":"forbidden"});
5182 assert!(executor.execute(&call).await.is_err());
5183 assert!(executor.execute_with_context(&call, ctx).await.is_err());
5184 assert!(executor
5185 .execute_with_context_outcome(&call, ctx)
5186 .await
5187 .is_err());
5188 assert!(executor
5189 .execute_exact_with_context_outcome(&call, "Write", ctx)
5190 .await
5191 .is_err());
5192 assert!(executor.check_permissions_for(&call, &ctx).await.is_err());
5193 assert!(executor
5194 .check_permissions_for_exact(&call, "Write", &ctx)
5195 .await
5196 .is_err());
5197 assert!(executor
5198 .check_permissions_for_resolved(&call, "Write", &args, &ctx)
5199 .await
5200 .is_err());
5201 executor.register_tool(EchoArgsTool).unwrap();
5202 assert!(executor
5203 .execute(&make_tool_call("echo_args", json!({"v":"late"})))
5204 .await
5205 .is_err());
5206 for name in [
5207 "SubAgent",
5208 "load_skill",
5209 "read_skill_resource",
5210 "composition",
5211 "Bash",
5212 "Edit",
5213 "Grep",
5214 ] {
5215 let forged = make_tool_call(name, json!({}));
5216 assert!(executor.execute(&forged).await.is_err());
5217 }
5218 assert!(!path.exists());
5219 assert_eq!(
5220 executor
5221 .list_tools()
5222 .iter()
5223 .map(|schema| schema.function.name.as_str())
5224 .collect::<BTreeSet<_>>(),
5225 BTreeSet::from(["Read", "Glob"])
5226 );
5227 assert!(!executor.owns_exact_tool("Write"));
5228 assert!(executor.exact_tool_owner("Write").is_none());
5229 assert!(executor.get_guide("Write").is_none());
5230 assert_eq!(
5231 executor.tool_mutability("Write"),
5232 crate::ToolMutability::Mutating
5233 );
5234 assert!(!executor.tool_concurrency_safe("Write"));
5235 assert!(!executor.call_parallel_classification(&call).1);
5236 fs::write(&path, "actual native read").await.unwrap();
5237 let read = make_tool_call("default::read_file", json!({"path":path}));
5238 assert!(executor.execute(&read).await.unwrap().success);
5239 assert!(BuiltinToolExecutor::with_registry(ToolRegistry::new())
5240 .with_native_tool_ceiling(vec!["Read".into()])
5241 .is_err());
5242 assert!(BuiltinToolExecutor::new()
5243 .with_native_tool_ceiling(vec!["Read".into(), "Read".into()])
5244 .is_err());
5245 assert!(BuiltinToolExecutor::new()
5246 .with_native_tool_ceiling(vec!["SubAgent".into()])
5247 .is_err());
5248 assert!(BuiltinToolExecutorBuilder::new()
5249 .with_default_tools()
5250 .build()
5251 .with_native_tool_ceiling(vec!["Grep".into()])
5252 .is_err());
5253 }
5254
5255 #[test]
5256 fn native_ceiling_admits_original_polled_shells_and_never_expands_frozen_grants() {
5257 let executor = BuiltinToolExecutor::new()
5258 .with_native_tool_ceiling(
5259 BuiltinToolExecutor::NATIVE_TOOL_NAMES
5260 .into_iter()
5261 .map(str::to_owned)
5262 .collect(),
5263 )
5264 .unwrap();
5265 for name in ["Bash", "exec_command", "write_stdin"] {
5266 assert!(executor.eligible_native_tool(name));
5267 assert!(executor.owns_exact_tool(name));
5268 }
5269 for name in [
5270 "default::exec_command",
5271 "Exec_Command",
5272 "BashInput",
5273 "BashOutput",
5274 "KillShell",
5275 ] {
5276 assert!(!executor.eligible_native_tool(name));
5277 }
5278 let narrowed = executor
5279 .with_native_tool_ceiling(vec!["Bash".into()])
5280 .unwrap();
5281 assert!(narrowed.owns_exact_tool("Bash"));
5282 assert!(!narrowed.owns_exact_tool("exec_command"));
5283 assert!(!narrowed.owns_exact_tool("write_stdin"));
5284 assert!(narrowed
5285 .with_native_tool_ceiling(vec!["exec_command".into()])
5286 .is_err());
5287 for name in ["exec_command", "write_stdin"] {
5288 let registry = ToolRegistry::new();
5289 registry
5290 .register(ExactRoutingTool {
5291 name,
5292 label: "spoofed native shell",
5293 args_sensitive: false,
5294 })
5295 .unwrap();
5296 assert!(BuiltinToolExecutor::with_registry(registry)
5297 .with_native_tool_ceiling(vec![name.into()])
5298 .is_err());
5299 }
5300 }
5301
5302 #[tokio::test]
5303 async fn task_only_native_ceiling_has_no_filesystem_or_shell_surface() {
5304 let executor = BuiltinToolExecutor::new()
5305 .with_native_tool_ceiling(vec!["Task".into()])
5306 .unwrap();
5307 assert_eq!(
5308 executor
5309 .list_tools()
5310 .iter()
5311 .map(|s| s.function.name.as_str())
5312 .collect::<Vec<_>>(),
5313 vec!["Task"]
5314 );
5315 let call = make_tool_call(
5316 "Task",
5317 json!({"tasks":[{"content":"Own step","status":"pending"}]}),
5318 );
5319 assert!(executor.execute(&call).await.unwrap().success);
5320 for name in [
5321 "Bash",
5322 "exec_command",
5323 "write_stdin",
5324 "Read",
5325 "Write",
5326 "Edit",
5327 "SubAgent",
5328 ] {
5329 assert!(executor
5330 .execute(&make_tool_call(name, json!({})))
5331 .await
5332 .is_err());
5333 assert!(!executor.owns_exact_tool(name));
5334 }
5335 assert!(executor.eligible_native_tool("Task"));
5336 }
5337
5338 struct CeilingReplacement(Arc<AtomicUsize>);
5339 #[async_trait]
5340 impl Tool for CeilingReplacement {
5341 fn name(&self) -> &str {
5342 "Write"
5343 }
5344 fn description(&self) -> &str {
5345 "custom replacement"
5346 }
5347 fn parameters_schema(&self) -> serde_json::Value {
5348 json!({"type":"object"})
5349 }
5350 async fn invoke(&self, _: serde_json::Value, _: ToolCtx) -> Result<ToolOutcome, ToolError> {
5351 self.0.fetch_add(1, Ordering::SeqCst);
5352 Err(ToolError::Execution("replacement invoked".into()))
5353 }
5354 }
5355 struct CeilingPermissionBarrier {
5356 entered: tokio::sync::Notify,
5357 release: tokio::sync::Notify,
5358 }
5359 #[async_trait]
5360 impl PermissionChecker for CeilingPermissionBarrier {
5361 async fn needs_confirmation(&self, _: crate::permission::PermissionType, _: &str) -> bool {
5362 self.entered.notify_one();
5363 self.release.notified().await;
5364 false
5365 }
5366 async fn request_confirmation(
5367 &self,
5368 _: crate::permission::PermissionContext,
5369 ) -> Result<bool, PermissionError> {
5370 Ok(true)
5371 }
5372 fn grant_session_permission(&self, _: crate::permission::PermissionType, _: String) {}
5373 }
5374 #[tokio::test]
5375 async fn native_ceiling_captured_original_survives_permission_await_without_invoking_replacement(
5376 ) {
5377 let dir = tempfile::tempdir().unwrap();
5378 let path = dir.path().join("captured.txt");
5379 let checker = Arc::new(CeilingPermissionBarrier {
5380 entered: Default::default(),
5381 release: Default::default(),
5382 });
5383 let executor = Arc::new(
5384 BuiltinToolExecutor::new_with_permissions(checker.clone())
5385 .with_native_tool_ceiling(vec!["Write".into()])
5386 .unwrap(),
5387 );
5388 let call = make_tool_call(
5389 "Write",
5390 json!({"file_path":path,"content":"original native"}),
5391 );
5392 let owned_executor = executor.clone();
5393 let owned_call = call.clone();
5394 let running = tokio::spawn(async move { owned_executor.execute(&owned_call).await });
5395 tokio::time::timeout(
5396 std::time::Duration::from_secs(5),
5397 checker.entered.notified(),
5398 )
5399 .await
5400 .unwrap();
5401 let invocations = Arc::new(AtomicUsize::new(0));
5402 assert!(executor.registry().unregister("Write"));
5403 executor
5404 .register_tool(CeilingReplacement(invocations.clone()))
5405 .unwrap();
5406 checker.release.notify_one();
5407 assert!(running.await.unwrap().unwrap().success);
5408 assert_eq!(fs::read_to_string(&path).await.unwrap(), "original native");
5409 assert!(executor.execute(&call).await.is_err());
5410 assert_eq!(invocations.load(Ordering::SeqCst), 0);
5411 assert!(executor.list_tools().is_empty());
5412 }
5413
5414 #[tokio::test]
5415 async fn native_ceiling_keeps_read_only_and_resource_hard_denies_under_all_modes() {
5416 let dir = tempfile::tempdir().unwrap();
5417 let path = dir.path().join("never.txt");
5418 let base = Arc::new(crate::permission::AllowAllPermissionChecker);
5419 let checker = Arc::new(crate::permission::ReadOnlyCommandChecker::new(base));
5420 let executor = BuiltinToolExecutor::new_with_permissions(checker)
5421 .with_native_tool_ceiling(vec!["Write".into()])
5422 .unwrap();
5423 let call = make_tool_call("Write", json!({"file_path":path,"content":"denied"}));
5424 let policy = Arc::new(crate::permission::PermissionConfig::new());
5425 policy.add_rule(crate::permission::PermissionRule::new(
5426 crate::permission::PermissionType::WriteFile,
5427 path.to_string_lossy(),
5428 false,
5429 ));
5430 let resource_denied = BuiltinToolExecutor::new_with_permissions(Arc::new(
5431 crate::permission::ConfigPermissionChecker::new(policy),
5432 ))
5433 .with_native_tool_ceiling(vec!["Write".into()])
5434 .unwrap();
5435 for (auto_approve_permissions, bypass_permissions) in
5436 [(false, false), (true, false), (false, true)]
5437 {
5438 let ctx = ToolExecutionContext {
5439 auto_approve_permissions,
5440 bypass_permissions,
5441 ..ToolExecutionContext::none(&call.id)
5442 };
5443 assert!(executor.execute_with_context(&call, ctx).await.is_err());
5444 assert!(resource_denied
5445 .execute_with_context(&call, ctx)
5446 .await
5447 .is_err());
5448 }
5449 assert!(!path.exists());
5450 }
5451 #[tokio::test]
5452 async fn local_task_resources_owned_write_survives_awaiter_abort_and_preserves_scopes() {
5453 struct Proxy;
5454 #[async_trait]
5455 impl crate::ApprovalProxy for Proxy {
5456 async fn request_approval(&self, _: crate::ApprovalAsk) -> bool {
5457 false
5458 }
5459 }
5460 struct Probe {
5461 entered: Arc<tokio::sync::Notify>,
5462 release: Arc<tokio::sync::Notify>,
5463 path: std::path::PathBuf,
5464 }
5465 #[async_trait]
5466 impl Tool for Probe {
5467 fn name(&self) -> &str {
5468 "Write"
5469 }
5470 fn description(&self) -> &str {
5471 "owned invocation test fixture"
5472 }
5473 fn parameters_schema(&self) -> serde_json::Value {
5474 json!({"type":"object"})
5475 }
5476 async fn invoke(
5477 &self,
5478 _: serde_json::Value,
5479 ctx: bamboo_agent_core::ToolCtx,
5480 ) -> Result<ToolOutcome, ToolError> {
5481 assert!(crate::current_approval_proxy().is_some());
5482 assert_eq!(
5483 crate::current_hook_permission_override(ctx.tool_call_id.as_ref()),
5484 Some(crate::HookPermissionOverride::Allow)
5485 );
5486 assert_eq!(
5487 bamboo_agent_core::tools::observed_tool_output_cap(&ctx),
5488 Some(77)
5489 );
5490 assert!(matches!(
5491 bamboo_agent_core::tools::context::root_actor_tool_writer(),
5492 Some(None)
5493 ));
5494 self.entered.notify_one();
5495 self.release.notified().await;
5496 tokio::fs::write(&self.path, "finished after awaiter cancellation")
5497 .await
5498 .unwrap();
5499 Ok(ToolOutcome::Completed(ToolResult {
5500 success: true,
5501 result: "done".into(),
5502 display_preference: None,
5503 images: vec![],
5504 }))
5505 }
5506 }
5507 let directory = tempfile::tempdir().unwrap();
5508 let path = directory.path().join("write.txt");
5509 let entered = Arc::new(tokio::sync::Notify::new());
5510 let release = Arc::new(tokio::sync::Notify::new());
5511 let tool: Arc<dyn Tool> = Arc::new(Probe {
5512 entered: entered.clone(),
5513 release: release.clone(),
5514 path: path.clone(),
5515 });
5516 let registry = ToolRegistry::new();
5517 registry.register_shared(tool.clone()).unwrap();
5518 let executor = BuiltinToolExecutor {
5519 registry,
5520 permission_checker: None,
5521 framework_builtin_tools: BTreeMap::from([("Write".into(), tool)]),
5522 native_tool_ceiling: None,
5523 tool_event_publisher: BuiltinToolExecutor::default_tool_event_publisher(),
5524 };
5525 let resources =
5526 crate::LocalTaskResources::new("local-probe", "write-run", chrono::Utc::now());
5527 let owned = resources.clone();
5528 let call = make_tool_call("Write", json!({"file_path":path,"content":"value"}));
5529 let invocation = tokio::spawn(async move {
5530 let ctx = ToolExecutionContext {
5531 session_id: Some("local-probe"),
5532 ..ToolExecutionContext::none(&call.id)
5533 };
5534 let invoke = executor.execute_with_context_outcome(&call, ctx);
5535 let invoke =
5536 bamboo_agent_core::tools::context::with_root_actor_tool_writer(None, invoke);
5537 let invoke = bamboo_agent_core::tools::scope_tool_output_cap(
5538 "local-probe",
5539 &call.id,
5540 Some(77),
5541 invoke,
5542 );
5543 let invoke = crate::with_approval_proxy(Some(Arc::new(Proxy)), invoke);
5544 let invoke = crate::with_hook_permission_override(
5545 Some(crate::HookPermissionOverride::Allow),
5546 &call.id,
5547 invoke,
5548 );
5549 owned.scope(owned.guard_tool_invocation(invoke)).await
5550 });
5551 entered.notified().await;
5552 invocation.abort();
5553 assert!(invocation.await.unwrap_err().is_cancelled());
5554 assert!(tokio::time::timeout(
5555 std::time::Duration::from_millis(20),
5556 resources.close_and_drain()
5557 )
5558 .await
5559 .is_err());
5560 assert!(!path.exists());
5561 release.notify_one();
5562 resources.close_and_drain().await.unwrap();
5563 assert_eq!(
5564 tokio::fs::read_to_string(&path).await.unwrap(),
5565 "finished after awaiter cancellation"
5566 );
5567 }
5568
5569 #[tokio::test]
5570 async fn local_task_resources_permission_denial_precedes_owned_invocation() {
5571 let directory = tempfile::tempdir().unwrap();
5572 let path = directory.path().join("must-not-write.txt");
5573 let checker = Arc::new(crate::permission::ReadOnlyCommandChecker::new(Arc::new(
5574 crate::permission::AllowAllPermissionChecker,
5575 )));
5576 let executor = BuiltinToolExecutor::new_with_permissions(checker);
5577 let resources = crate::LocalTaskResources::new("denied", "denied-run", chrono::Utc::now());
5578 let call = make_tool_call("Write", json!({"file_path":path,"content":"denied"}));
5579 assert!(resources.scope(executor.execute(&call)).await.is_err());
5580 resources.close_and_drain().await.unwrap();
5581 assert!(!path.exists());
5582 }
5583 mod suspension_contract_tests {
5584 use super::*;
5585 use bamboo_agent_core::tools::{suspension_allowed, with_suspension_disabled};
5586 use std::sync::atomic::{AtomicUsize, Ordering};
5587
5588 #[tokio::test]
5589 async fn disabled_suspension_rejects_typed_ask_before_proxy_event_pending_or_write() {
5590 for proxy_enabled in [false, true] {
5591 let directory = tempfile::tempdir().unwrap();
5592 let path = directory.path().join("must-not-write.txt");
5593 let config = Arc::new(crate::permission::PermissionConfig::new());
5594 config.set_ask_rules([format!("Write({})", path.display())]);
5595 config.register_session_workspace("scope-ask", directory.path().to_string_lossy());
5596 let executor = BuiltinToolExecutor::new_with_permissions(Arc::new(
5597 crate::permission::ConfigPermissionChecker::new(config.clone()),
5598 ));
5599 let call = make_tool_call("Write", json!({"file_path":path,"content":"forbidden"}));
5600 let requests = Arc::new(AtomicUsize::new(0));
5601 let proxy: Option<Arc<dyn crate::approval::ApprovalProxy>> =
5602 proxy_enabled.then(|| {
5603 Arc::new(RecordingApprovalProxy {
5604 requests: requests.clone(),
5605 approve: false,
5606 }) as Arc<dyn crate::approval::ApprovalProxy>
5607 });
5608 let (tx, mut rx) = mpsc::channel(8);
5609 let ctx = ToolExecutionContext {
5610 session_id: Some("scope-ask"),
5611 event_tx: Some(&tx),
5612 ..ToolExecutionContext::none(&call.id)
5613 };
5614 let result = with_suspension_disabled(crate::with_approval_proxy(
5615 proxy,
5616 executor.execute_with_context_outcome(&call, ctx),
5617 ))
5618 .await;
5619 assert!(
5620 matches!(result, Err(ToolError::Execution(ref reason)) if reason.contains("suspension_disabled")),
5621 "{result:?}"
5622 );
5623 assert_eq!(requests.load(Ordering::SeqCst), 0);
5624 assert!(config.pending_request("scope-ask", &call.id).is_none());
5625 assert!(rx.try_recv().is_err(), "must not publish an approval event");
5626 assert!(!path.exists());
5627 assert!(suspension_allowed());
5628 }
5629 }
5630
5631 struct InteractiveChecker(Arc<AtomicUsize>);
5632 #[async_trait]
5633 impl crate::permission::PermissionChecker for InteractiveChecker {
5634 async fn needs_confirmation(
5635 &self,
5636 _: crate::permission::PermissionType,
5637 _: &str,
5638 ) -> bool {
5639 true
5640 }
5641 async fn request_confirmation(
5642 &self,
5643 ctx: crate::permission::PermissionContext,
5644 ) -> Result<bool, crate::permission::PermissionError> {
5645 self.0.fetch_add(1, Ordering::SeqCst);
5646 Err(crate::permission::PermissionError::confirmation_required(
5647 ctx,
5648 ))
5649 }
5650 fn grant_session_permission(&self, _: crate::permission::PermissionType, _: String) {}
5651 }
5652
5653 #[tokio::test]
5654 async fn disabled_suspension_rejects_configless_checker_before_real_confirmation() {
5655 let directory = tempfile::tempdir().unwrap();
5656 let path = directory.path().join("must-not-write.txt");
5657 let requests = Arc::new(AtomicUsize::new(0));
5658 let executor = BuiltinToolExecutor::new_with_permissions(Arc::new(InteractiveChecker(
5659 requests.clone(),
5660 )));
5661 let call = make_tool_call("Write", json!({"file_path":path,"content":"forbidden"}));
5662 let (tx, mut rx) = mpsc::channel(8);
5663 let ctx = ToolExecutionContext {
5664 session_id: Some("configless"),
5665 event_tx: Some(&tx),
5666 ..ToolExecutionContext::none(&call.id)
5667 };
5668 let result =
5669 with_suspension_disabled(executor.execute_with_context_outcome(&call, ctx)).await;
5670 assert!(
5671 matches!(result, Err(ToolError::Execution(ref reason)) if reason.contains("suspension_disabled")),
5672 "{result:?}"
5673 );
5674 assert_eq!(requests.load(Ordering::SeqCst), 0);
5675 assert!(rx.try_recv().is_err());
5676 assert!(!path.exists());
5677 }
5678
5679 #[tokio::test]
5680 async fn disabled_suspension_keeps_existing_auto_bypass_and_plan_gates() {
5681 for (auto, bypass, plan, allowed) in [
5682 (true, false, false, true),
5683 (false, true, false, true),
5684 (true, false, true, false),
5685 ] {
5686 let directory = tempfile::tempdir().unwrap();
5687 let path = directory.path().join("posture.txt");
5688 let config = Arc::new(crate::permission::PermissionConfig::new());
5689 let executor = BuiltinToolExecutor::new_with_permissions(Arc::new(
5690 crate::permission::ConfigPermissionChecker::new(config),
5691 ));
5692 let call = make_tool_call(
5693 "Write",
5694 json!({"file_path":path,"content":"explicit posture"}),
5695 );
5696 let (tx, mut rx) = mpsc::channel(8);
5697 let ctx = ToolExecutionContext {
5698 session_id: Some("posture"),
5699 event_tx: Some(&tx),
5700 auto_approve_permissions: auto,
5701 bypass_permissions: bypass,
5702 plan_read_only: plan,
5703 ..ToolExecutionContext::none(&call.id)
5704 };
5705 let result =
5706 with_suspension_disabled(executor.execute_with_context_outcome(&call, ctx))
5707 .await;
5708 assert_eq!(
5709 result.is_ok(),
5710 allowed,
5711 "auto={auto},bypass={bypass},plan={plan}: {result:?}"
5712 );
5713 assert_eq!(path.exists(), allowed);
5714 assert!(rx.try_recv().is_err());
5715 }
5716 }
5717
5718 #[tokio::test]
5719 async fn ordinary_execution_still_publishes_and_retains_typed_approval() {
5720 let directory = tempfile::tempdir().unwrap();
5721 let path = directory.path().join("ordinary-ask.txt");
5722 let config = Arc::new(crate::permission::PermissionConfig::new());
5723 config.set_ask_rules([format!("Write({})", path.display())]);
5724 let executor = BuiltinToolExecutor::new_with_permissions(Arc::new(
5725 crate::permission::ConfigPermissionChecker::new(config.clone()),
5726 ));
5727 let call = make_tool_call(
5728 "Write",
5729 json!({"file_path":path,"content":"approval required"}),
5730 );
5731 let (tx, mut rx) = mpsc::channel(8);
5732 let ctx = ToolExecutionContext {
5733 session_id: Some("ordinary-ask"),
5734 event_tx: Some(&tx),
5735 ..ToolExecutionContext::none(&call.id)
5736 };
5737 let outcome = executor
5738 .execute_with_context_outcome(&call, ctx)
5739 .await
5740 .unwrap();
5741 let result = outcome.into_tool_result();
5742 assert_eq!(
5743 result.display_preference.as_deref(),
5744 Some("request_permissions")
5745 );
5746 assert!(config.pending_request("ordinary-ask", &call.id).is_some());
5747 assert!(matches!(
5748 rx.try_recv().unwrap(),
5749 AgentEvent::ToolApprovalRequested { .. }
5750 ));
5751 assert!(!path.exists());
5752 }
5753 }
5754
5755 mod owned_suspension_rebind_tests {
5756 use super::*;
5757 use bamboo_agent_core::tools::with_suspension_disabled;
5758 #[tokio::test]
5759 async fn managed_owned_builtin_keeps_suspension_denial_before_background_bash_spawn() {
5760 let directory = tempfile::tempdir().unwrap();
5761 let session_id = "owned-rebind-background-probe";
5762 let resources =
5763 crate::LocalTaskResources::new(session_id, "probe-run", chrono::Utc::now());
5764 let executor = BuiltinToolExecutor::new();
5765 let call = make_tool_call(
5766 "Bash",
5767 json!({"command":"exit 0","run_in_background":true,"workdir":directory.path()}),
5768 );
5769 let (tx, mut rx) = mpsc::channel(32);
5770 let ctx = ToolExecutionContext {
5771 session_id: Some(session_id),
5772 event_tx: Some(&tx),
5773 can_async_resume: true,
5774 ..ToolExecutionContext::none(&call.id)
5775 };
5776 let result = with_suspension_disabled(
5777 resources.scope(executor.execute_with_context_outcome(&call, ctx)),
5778 )
5779 .await;
5780 if let Ok(ToolOutcome::Completed(output)) = &result {
5783 if let Some(id) = serde_json::from_str::<serde_json::Value>(&output.result)
5784 .ok()
5785 .and_then(|v| v["bash_id"].as_str().map(str::to_owned))
5786 {
5787 if let Some(shell) = crate::tools::bash_runtime::get_shell(&id) {
5788 let _ = shell.kill().await;
5789 }
5790 }
5791 }
5792 let drained = resources.close_and_drain().await;
5793 let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::<Vec<_>>();
5794 assert!(
5795 matches!(result,Err(ToolError::Execution(ref text)) if text.contains("suspension_disabled")),
5796 "{result:?}"
5797 );
5798 drained.unwrap();
5799 assert!(
5800 events.is_empty(),
5801 "no detached shell event should be published: {events:?}"
5802 );
5803 assert!(crate::tools::bash_runtime::running_shells_for_session(session_id).is_empty());
5804 }
5805 }
5806
5807 mod owned_late_running_helper_tests {
5808 use super::*;
5809 use bamboo_agent_core::tools::{
5810 capture_suspension_restriction, with_suspension_disabled, AsyncWaitKind,
5811 RunningCompletion, RunningHandle,
5812 };
5813 use std::{
5814 future::Future,
5815 pin::Pin,
5816 sync::atomic::{AtomicUsize, Ordering},
5817 task::{Context, Poll},
5818 time::Duration,
5819 };
5820 struct NeverDrive {
5821 polls: Arc<AtomicUsize>,
5822 drops: Arc<AtomicUsize>,
5823 }
5824 impl Future for NeverDrive {
5825 type Output = ToolResult;
5826 fn poll(self: Pin<&mut Self>, _: &mut Context<'_>) -> Poll<ToolResult> {
5827 self.polls.fetch_add(1, Ordering::SeqCst);
5828 panic!("restricted completion must not be polled");
5829 }
5830 }
5831 impl Drop for NeverDrive {
5832 fn drop(&mut self) {
5833 self.drops.fetch_add(1, Ordering::SeqCst);
5834 }
5835 }
5836 #[tokio::test]
5839 async fn owned_completion_guard_survives_abort_of_registered_awaiter() {
5840 for panic_kill in [false, true] {
5841 let resources = crate::LocalTaskResources::new(
5842 "late-running-helper",
5843 "private-owned-probe",
5844 chrono::Utc::now(),
5845 );
5846 let owned_resources = resources.clone();
5847 let kills = Arc::new(AtomicUsize::new(0));
5848 let polls = Arc::new(AtomicUsize::new(0));
5849 let drops = Arc::new(AtomicUsize::new(0));
5850 let child_kills = kills.clone();
5851 let child_polls = polls.clone();
5852 let child_drops = drops.clone();
5853 let (observed_tx, observed_rx) = tokio::sync::oneshot::channel();
5854 let (started_tx, started_rx) = tokio::sync::oneshot::channel();
5855 let (release_tx, release_rx) = tokio::sync::oneshot::channel();
5856 let (completed_tx, completed_rx) = tokio::sync::oneshot::channel();
5857 let parent = tokio::spawn(async move {
5858 with_suspension_disabled(async move {
5859 let token = capture_suspension_restriction().unwrap();
5860 let child_token = token.clone();
5861 let registered = owned_resources
5862 .spawn_owned(async move {
5863 child_token
5864 .scope(async move {
5865 let _ = started_tx.send(());
5866 let _ = release_rx.await;
5867 let outcome = ToolOutcome::Running(RunningHandle {
5868 tool_call_id: "late-running".into(),
5869 ack: ToolResult::text(
5870 true,
5871 "must not publish this ack",
5872 ),
5873 completion: RunningCompletion::Driven(Box::pin(
5874 NeverDrive {
5875 polls: child_polls,
5876 drops: child_drops,
5877 },
5878 )),
5879 wait_kind: AsyncWaitKind::AsyncTools,
5880 kill: Box::new(move || {
5881 child_kills.fetch_add(1, Ordering::SeqCst);
5882 assert!(!panic_kill, "injected cleanup panic");
5883 }),
5884 });
5885 let outcome =
5888 enforce_registered_tool_suspension(Ok(outcome))
5889 .unwrap();
5890 let result =
5891 enforce_registered_tool_suspension(Ok(outcome))
5892 .unwrap()
5893 .into_tool_result();
5894 let _ = completed_tx.send(result);
5895 })
5896 .await;
5897 })
5898 .unwrap();
5899 let _ = observed_tx.send(token);
5900 registered.await
5901 })
5902 .await
5903 });
5904 let started = tokio::time::timeout(Duration::from_secs(5), started_rx).await;
5905 let observed = tokio::time::timeout(Duration::from_secs(5), observed_rx).await;
5906 parent.abort();
5907 let parent_exit = parent.await;
5908 let _ = release_tx.send(());
5909 let completed = tokio::time::timeout(Duration::from_secs(5), completed_rx).await;
5910 let drained =
5911 tokio::time::timeout(Duration::from_secs(5), resources.close_and_drain()).await;
5912 assert!(started.unwrap().is_ok());
5914 assert!(parent_exit.unwrap_err().is_cancelled());
5915 let report = observed.unwrap().unwrap().observation();
5916 let result = completed.unwrap().unwrap();
5917 assert!(!result.success);
5918 assert!(result.result.contains("stop unconfirmed"));
5919 assert!(!result.result.contains("must not publish this ack"));
5920 assert!(report.running_seen && report.forbidden_running_seen);
5921 assert_eq!(kills.load(Ordering::SeqCst), 1);
5922 assert_eq!(polls.load(Ordering::SeqCst), 0);
5923 assert_eq!(drops.load(Ordering::SeqCst), 1);
5924 let error = drained.unwrap().unwrap_err();
5925 assert!(error
5926 .to_string()
5927 .contains("cooperative kill has no stop receipt"));
5928 }
5929 }
5930 }
5931}