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