Skip to main content

bamboo_tools/
executor.rs

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