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