Skip to main content

bamboo_tools/
executor.rs

1use std::collections::BTreeMap;
2use std::panic::{catch_unwind, AssertUnwindSafe};
3use std::sync::Arc;
4
5use async_trait::async_trait;
6use bamboo_agent_core::tools::input_guard::{check_parsed_tool_input, check_raw_tool_input};
7use bamboo_agent_core::{
8    parse_tool_args_best_effort, Tool, ToolCall, ToolError, ToolExecutionContext, ToolExecutor,
9    ToolOutcome, ToolResult, ToolSchema,
10};
11use bamboo_domain::{canonical_tool_name, resolve_tool_reference_name};
12
13use crate::guide::{context::GuideBuildContext, EnhancedPromptBuilder, ToolGuide};
14use crate::permission::{check_permissions, PermissionChecker, PermissionError};
15use crate::tools::{
16    BashInputTool, BashOutputTool, BashTool, ConclusionWithOptionsTool, EditTool,
17    EnterPlanModeTool, ExitPlanModeTool, GetFileInfoTool, GlobTool, GrepTool, JsReplTool,
18    KillShellTool, NotebookEditTool, ReadTool, RequestPermissionsTool, SessionNoteTool, SleepTool,
19    TaskTool, ToolRegistry, UpdateGoalTool, WebFetchTool, WebSearchTool, WorkspaceTool, WriteTool,
20};
21use bamboo_llm::Config;
22use bamboo_plugin_protocol::{
23    FileChangedV1, NoopToolEventPublisher, ToolEventContextV1, ToolEventPublisher, ToolEventV1,
24};
25use tokio::sync::RwLock;
26
27fn preview_for_log(value: &str, max_chars: usize) -> String {
28    let mut iter = value.chars();
29    let mut preview = String::new();
30    for _ in 0..max_chars {
31        match iter.next() {
32            Some(ch) => preview.push(ch),
33            None => break,
34        }
35    }
36    if iter.next().is_some() {
37        preview.push_str("...");
38    }
39    preview.replace('\n', "\\n").replace('\r', "\\r")
40}
41
42fn copy_legacy_arg_if_missing(
43    args: &mut serde_json::Map<String, serde_json::Value>,
44    from: &str,
45    to: &str,
46) {
47    if args.contains_key(to) {
48        return;
49    }
50    if let Some(value) = args.get(from).cloned() {
51        args.insert(to.to_string(), value);
52    }
53}
54
55fn normalize_legacy_builtin_args(
56    raw_tool_name: &str,
57    args: &mut serde_json::Map<String, serde_json::Value>,
58) {
59    match raw_tool_name {
60        "read_file" | "write_file" | "Read" | "Write" | "apply_patch" => {
61            copy_legacy_arg_if_missing(args, "path", "file_path");
62        }
63        "execute_command" | "Bash" => {
64            copy_legacy_arg_if_missing(args, "cmd", "command");
65        }
66        "list_directory" | "Glob" => {
67            let should_default_pattern = raw_tool_name == "list_directory"
68                || args.contains_key("path")
69                || args.contains_key("recursive");
70            if should_default_pattern && !args.contains_key("pattern") {
71                let recursive = args
72                    .get("recursive")
73                    .and_then(serde_json::Value::as_bool)
74                    .unwrap_or(false);
75                let pattern = if recursive { "**/*" } else { "*" };
76                args.insert(
77                    "pattern".to_string(),
78                    serde_json::Value::String(pattern.to_string()),
79                );
80            }
81            args.remove("recursive");
82        }
83        _ => {}
84    }
85}
86
87fn resolve_registered_tool_name(registry: &ToolRegistry, reference: &str) -> Option<String> {
88    resolve_tool_reference_name(reference, |candidate| registry.contains(candidate))
89}
90
91/// Apply compatibility argument aliases only after the registry identity and
92/// its framework-owned implementation provenance are resolved. Exact custom
93/// tools whose names merely resemble a builtin or alias (for example an exact
94/// `Read` or `apply_patch`) must receive their original arguments.
95fn normalize_resolved_builtin_args(
96    reference: &str,
97    execution_name: &str,
98    args: &mut serde_json::Value,
99) {
100    if !matches!(execution_name, "Read" | "Write" | "Edit" | "Bash" | "Glob") {
101        return;
102    }
103    let unqualified = reference
104        .trim()
105        .rsplit("::")
106        .next()
107        .unwrap_or(reference)
108        .trim();
109    if let Some(args_obj) = args.as_object_mut() {
110        normalize_legacy_builtin_args(unqualified, args_obj);
111    }
112}
113
114/// Built-in tool executor that uses ToolRegistry for dynamic dispatch
115pub struct BuiltinToolExecutor {
116    registry: ToolRegistry,
117    permission_checker: Option<Arc<dyn PermissionChecker>>,
118    /// Framework-owned tool instances whose identity affects compatibility
119    /// argument handling or file-change events. Arc identity prevents a custom
120    /// same-name registry replacement from inheriting builtin provenance.
121    framework_builtin_tools: BTreeMap<String, Arc<dyn Tool>>,
122    tool_event_publisher: Arc<dyn ToolEventPublisher>,
123}
124
125impl BuiltinToolExecutor {
126    fn default_tool_event_publisher() -> Arc<dyn ToolEventPublisher> {
127        Arc::new(NoopToolEventPublisher)
128    }
129
130    /// Creates a new executor with all built-in tools registered
131    pub fn new() -> Self {
132        let registry = ToolRegistry::new();
133        let framework_builtin_tools = Self::register_builtin_tools(&registry, None);
134        Self {
135            registry,
136            permission_checker: None,
137            framework_builtin_tools,
138            tool_event_publisher: Self::default_tool_event_publisher(),
139        }
140    }
141
142    /// Creates a new executor with a permission checker
143    pub fn new_with_permissions(permission_checker: Arc<dyn PermissionChecker>) -> Self {
144        let registry = ToolRegistry::new();
145        let framework_builtin_tools = Self::register_builtin_tools(&registry, None);
146        Self {
147            registry,
148            permission_checker: Some(permission_checker),
149            framework_builtin_tools,
150            tool_event_publisher: Self::default_tool_event_publisher(),
151        }
152    }
153
154    /// Creates a new executor that can read the shared, hot-reloadable config.
155    ///
156    /// Use this when running inside the Bamboo server so tools (notably
157    /// `http_request`) honor proxy settings from `config.json`.
158    pub fn new_with_config(config: Arc<RwLock<Config>>) -> Self {
159        let registry = ToolRegistry::new();
160        let framework_builtin_tools = Self::register_builtin_tools(&registry, Some(config));
161        Self {
162            registry,
163            permission_checker: None,
164            framework_builtin_tools,
165            tool_event_publisher: Self::default_tool_event_publisher(),
166        }
167    }
168
169    /// Creates a new executor with both shared config and a permission checker.
170    pub fn new_with_config_and_permissions(
171        config: Arc<RwLock<Config>>,
172        permission_checker: Arc<dyn PermissionChecker>,
173    ) -> Self {
174        let registry = ToolRegistry::new();
175        let framework_builtin_tools = Self::register_builtin_tools(&registry, Some(config));
176        Self {
177            registry,
178            permission_checker: Some(permission_checker),
179            framework_builtin_tools,
180            tool_event_publisher: Self::default_tool_event_publisher(),
181        }
182    }
183
184    /// Creates a new executor from an existing registry
185    pub fn with_registry(registry: ToolRegistry) -> Self {
186        Self {
187            registry,
188            permission_checker: None,
189            framework_builtin_tools: BTreeMap::new(),
190            tool_event_publisher: Self::default_tool_event_publisher(),
191        }
192    }
193
194    /// Creates a new executor from an existing registry and permission checker.
195    ///
196    /// This is the dependency-injection counterpart to
197    /// [`new_with_permissions`](Self::new_with_permissions): callers that
198    /// intentionally expose a selected/custom registry can keep the canonical
199    /// permission gate instead of silently dropping it.
200    pub fn with_registry_and_permissions(
201        registry: ToolRegistry,
202        permission_checker: Arc<dyn PermissionChecker>,
203    ) -> Self {
204        Self {
205            registry,
206            permission_checker: Some(permission_checker),
207            framework_builtin_tools: BTreeMap::new(),
208            tool_event_publisher: Self::default_tool_event_publisher(),
209        }
210    }
211
212    /// Inject an instance-local, non-blocking tool-event publisher.
213    pub fn with_tool_event_publisher(mut self, publisher: Arc<dyn ToolEventPublisher>) -> Self {
214        self.tool_event_publisher = publisher;
215        self
216    }
217
218    /// Returns a reference to the internal registry
219    pub fn registry(&self) -> &ToolRegistry {
220        &self.registry
221    }
222
223    fn pending_file_changed(
224        &self,
225        tool_name: &str,
226        tool: &Arc<dyn Tool>,
227        args: &serde_json::Value,
228    ) -> Option<FileChangedV1> {
229        let builtin = self.framework_builtin_tools.get(tool_name)?;
230        if !Arc::ptr_eq(builtin, tool) {
231            return None;
232        }
233        let path_field = match tool_name {
234            "Write" | "Edit" => "file_path",
235            "NotebookEdit" => "notebook_path",
236            _ => return None,
237        };
238        let path = args.get(path_field)?.as_str()?.trim();
239        FileChangedV1::bounded_from(path).ok()
240    }
241
242    fn publish_successful_file_change(
243        &self,
244        ctx: &ToolExecutionContext<'_>,
245        tool_name: &str,
246        data: FileChangedV1,
247    ) {
248        let Some(session_id) = ctx.session_id else {
249            return;
250        };
251        let Some(root_session_id) = ctx.root_session_id else {
252            return;
253        };
254        let Ok(context) = ToolEventContextV1::bounded_from(
255            session_id,
256            root_session_id,
257            tool_name,
258            ctx.tool_call_id,
259        ) else {
260            return;
261        };
262        let Ok(event) = ToolEventV1::file_changed(context, data) else {
263            return;
264        };
265
266        // A buggy publisher must not unwind across the tool-result boundary.
267        // Returned failures are deliberately ignored: delivery is best-effort.
268        let publisher = self.tool_event_publisher.as_ref();
269        let _ = catch_unwind(AssertUnwindSafe(|| publisher.try_publish(event)));
270    }
271
272    /// Registers all built-in tools to the given registry
273    fn register_builtin_tools(
274        registry: &ToolRegistry,
275        config: Option<Arc<RwLock<Config>>>,
276    ) -> BTreeMap<String, Arc<dyn Tool>> {
277        let mut framework_tools = BTreeMap::new();
278        let _ = config;
279        // NOTE: apply_patch is now an alias for Edit – no separate registration.
280        let _ = registry.register(ConclusionWithOptionsTool::new());
281        if let Ok((name, tool)) = Self::register_tracked_builtin(registry, BashTool::new()) {
282            framework_tools.insert(name, tool);
283        }
284        let _ = registry.register(BashInputTool::new());
285        let _ = registry.register(BashOutputTool::new());
286        if let Ok((name, tool)) = Self::register_tracked_builtin(registry, EditTool::new()) {
287            framework_tools.insert(name, tool);
288        }
289        let _ = registry.register(EnterPlanModeTool::new());
290        let _ = registry.register(ExitPlanModeTool::new());
291        // NOTE: FileExists is now an alias for GetFileInfo – no separate registration.
292        let _ = registry.register(GetFileInfoTool::new());
293        if let Ok((name, tool)) = Self::register_tracked_builtin(registry, GlobTool::new()) {
294            framework_tools.insert(name, tool);
295        }
296        let _ = registry.register(GrepTool::new());
297        let _ = registry.register(UpdateGoalTool::new());
298        let _ = registry.register(JsReplTool::new());
299        let _ = registry.register(KillShellTool::new());
300        let _ = registry.register(SessionNoteTool::new());
301        if let Ok((name, tool)) = Self::register_tracked_builtin(registry, NotebookEditTool::new())
302        {
303            framework_tools.insert(name, tool);
304        }
305        if let Ok((name, tool)) = Self::register_tracked_builtin(registry, ReadTool::new()) {
306            framework_tools.insert(name, tool);
307        }
308        let _ = registry.register(RequestPermissionsTool::new());
309        let _ = registry.register(SleepTool::new());
310        let _ = registry.register(TaskTool::new());
311        let _ = registry.register(WebFetchTool::new());
312        let _ = registry.register(WebSearchTool::new());
313        // NOTE: GetCurrentDir + SetWorkspace are now aliases for Workspace.
314        let _ = registry.register(WorkspaceTool::new());
315        if let Ok((name, tool)) = Self::register_tracked_builtin(registry, WriteTool::new()) {
316            framework_tools.insert(name, tool);
317        }
318        framework_tools
319    }
320
321    fn register_tracked_builtin<T: Tool + 'static>(
322        registry: &ToolRegistry,
323        tool: T,
324    ) -> Result<(String, Arc<dyn Tool>), ToolError> {
325        let name = tool.name().to_string();
326        let tool: Arc<dyn Tool> = Arc::new(tool);
327        registry
328            .register_shared(tool.clone())
329            .map_err(|error| ToolError::Execution(error.to_string()))?;
330        Ok((name, tool))
331    }
332
333    fn is_framework_builtin_instance(&self, execution_name: &str, tool: &Arc<dyn Tool>) -> bool {
334        self.framework_builtin_tools
335            .get(execution_name)
336            .is_some_and(|builtin| Arc::ptr_eq(builtin, tool))
337    }
338
339    fn normalize_registered_builtin_args(
340        &self,
341        reference: &str,
342        execution_name: &str,
343        tool: &Arc<dyn Tool>,
344        args: &mut serde_json::Value,
345    ) {
346        if self.is_framework_builtin_instance(execution_name, tool) {
347            normalize_resolved_builtin_args(reference, execution_name, args);
348        }
349    }
350
351    /// Returns all built-in tool schemas
352    pub fn tool_schemas() -> Vec<ToolSchema> {
353        let registry = ToolRegistry::new();
354        let _ = Self::register_builtin_tools(&registry, None);
355        registry.list_tools()
356    }
357
358    /// Registers a custom tool to this executor
359    pub fn register_tool<T: Tool + 'static>(&self, tool: T) -> Result<(), ToolError> {
360        self.registry
361            .register(tool)
362            .map_err(|e| ToolError::Execution(e.to_string()))
363    }
364
365    /// Register a tool with its guide
366    pub fn register_tool_with_guide<T, G>(&self, tool: T, guide: G) -> Result<(), ToolError>
367    where
368        T: Tool + 'static,
369        G: ToolGuide + 'static,
370    {
371        self.registry
372            .register_with_guide(tool, guide)
373            .map_err(|e| ToolError::Execution(e.to_string()))
374    }
375
376    /// Get guide for a tool
377    pub fn get_guide(&self, tool_name: &str) -> Option<Arc<dyn ToolGuide>> {
378        self.registry.get_guide(tool_name)
379    }
380
381    fn parse_execution_args(
382        &self,
383        call: &ToolCall,
384        ctx: &ToolExecutionContext<'_>,
385    ) -> serde_json::Value {
386        if let Some(pre_parsed) = ctx.pre_parsed_args {
387            return pre_parsed.clone();
388        }
389        let args_raw = call.function.arguments.trim();
390        let (parsed, parse_warning) = parse_tool_args_best_effort(&call.function.arguments);
391        if let Some(warning) = parse_warning {
392            tracing::warn!(
393                "Builtin tool argument parsing fallback applied: session_id={:?}, tool_call_id={}, tool_name={}, args_len={}, args_preview=\"{}\", warning={}",
394                ctx.session_id,
395                call.id,
396                call.function.name,
397                args_raw.len(),
398                preview_for_log(args_raw, 180),
399                warning
400            );
401        }
402        parsed
403    }
404
405    async fn execute_registered_with_context_outcome(
406        &self,
407        call: &ToolCall,
408        execution_name: &str,
409        ctx: ToolExecutionContext<'_>,
410    ) -> Result<ToolOutcome, ToolError> {
411        let tool = self
412            .registry
413            .get(execution_name)
414            .ok_or_else(|| ToolError::NotFound(format!("Tool '{}' not found", execution_name)))?;
415        check_raw_tool_input(execution_name, &call.function.arguments)?;
416        let mut args = self.parse_execution_args(call, &ctx);
417        check_parsed_tool_input(execution_name, &args)?;
418        self.normalize_registered_builtin_args(
419            &call.function.name,
420            execution_name,
421            &tool,
422            &mut args,
423        );
424
425        if let Some(outcome) = self
426            .check_permissions_for_resolved(call, execution_name, &args, &ctx)
427            .await?
428        {
429            return Ok(outcome);
430        }
431
432        let publisher_enabled =
433            catch_unwind(AssertUnwindSafe(|| self.tool_event_publisher.is_enabled()))
434                .unwrap_or(false);
435        let pending_file_changed = publisher_enabled
436            .then(|| self.pending_file_changed(execution_name, &tool, &args))
437            .flatten();
438
439        let outcome = tool.invoke(args, ctx.to_tool_ctx()).await?;
440        if matches!(
441            &outcome,
442            ToolOutcome::Completed(result) if result.success
443        ) {
444            if let Some(data) = pending_file_changed {
445                self.publish_successful_file_change(&ctx, execution_name, data);
446            }
447        }
448        Ok(outcome)
449    }
450
451    /// Build enhanced prompt for all registered tools
452    pub fn build_enhanced_prompt(&self, context: GuideBuildContext) -> String {
453        EnhancedPromptBuilder::build(Some(&self.registry), &self.registry.list_tools(), &context)
454    }
455}
456
457fn permission_error_to_tool_error(error: PermissionError) -> ToolError {
458    match error {
459        PermissionError::CheckFailed(_) => ToolError::InvalidArguments(error.to_string()),
460        _ => ToolError::Execution(error.to_string()),
461    }
462}
463
464impl Default for BuiltinToolExecutor {
465    fn default() -> Self {
466        Self::new()
467    }
468}
469
470#[async_trait]
471impl ToolExecutor for BuiltinToolExecutor {
472    async fn execute(&self, call: &ToolCall) -> Result<ToolResult, ToolError> {
473        self.execute_with_context(call, ToolExecutionContext::none(&call.id))
474            .await
475    }
476
477    async fn execute_with_context(
478        &self,
479        call: &ToolCall,
480        ctx: ToolExecutionContext<'_>,
481    ) -> Result<ToolResult, ToolError> {
482        self.execute_with_context_outcome(call, ctx)
483            .await
484            .map(ToolOutcome::into_tool_result)
485    }
486
487    async fn execute_with_context_outcome(
488        &self,
489        call: &ToolCall,
490        ctx: ToolExecutionContext<'_>,
491    ) -> Result<ToolOutcome, ToolError> {
492        let reference = call.function.name.trim();
493        let tool_name =
494            resolve_registered_tool_name(&self.registry, reference).ok_or_else(|| {
495                ToolError::NotFound(format!("Tool '{}' not found", call.function.name))
496            })?;
497        self.execute_registered_with_context_outcome(call, &tool_name, ctx)
498            .await
499    }
500
501    async fn execute_exact_with_context_outcome(
502        &self,
503        call: &ToolCall,
504        execution_name: &str,
505        ctx: ToolExecutionContext<'_>,
506    ) -> Result<ToolOutcome, ToolError> {
507        self.execute_registered_with_context_outcome(call, execution_name, ctx)
508            .await
509    }
510
511    /// The real permission gate for built-in tools, extracted from the execute
512    /// path so it is reusable by wrapping executors (issue #341). The behavior is
513    /// byte-for-byte the same block that used to run inline in
514    /// `execute_with_context_outcome`:
515    ///
516    /// - resolves the SAME `tool_name` + `args` the execute path runs with (so
517    ///   the check sees exactly what the tool will run with);
518    /// - "always ask" rules (`requires_forced_confirmation`) force a confirmation
519    ///   even under bypass; everything else is skipped when the session is in
520    ///   bypass-permissions mode;
521    /// - forced confirmations route through `check_or_request_forced` so the
522    ///   active mode/bypass can't suppress the prompt;
523    /// - a `ConfirmationRequired` first tries the cross-process `ApprovalProxy`
524    ///   (a subagent worker forwarding to its host), then the interactive human
525    ///   sink (returning the synthesized approval pause as `Ok(Some(..))`), then
526    ///   fails closed;
527    /// - deny fails closed.
528    ///
529    /// The only mechanical difference from the old inline block: the interactive
530    /// pause is returned as `Ok(Some(outcome))` and a clean pass returns
531    /// `Ok(None)`, so the caller decides whether to run the tool. The fallback
532    /// arg-parse warning is intentionally NOT re-logged here — the execute path
533    /// already logs it once for this call.
534    async fn check_permissions_for(
535        &self,
536        call: &ToolCall,
537        ctx: &ToolExecutionContext<'_>,
538    ) -> Result<Option<ToolOutcome>, ToolError> {
539        let reference = call.function.name.trim();
540        let tool_name = resolve_registered_tool_name(&self.registry, reference)
541            .unwrap_or_else(|| canonical_tool_name(reference));
542        let mut args = if let Some(pre_parsed) = ctx.pre_parsed_args {
543            pre_parsed.clone()
544        } else {
545            parse_tool_args_best_effort(&call.function.arguments).0
546        };
547        if let Some(tool) = self.registry.get(&tool_name) {
548            self.normalize_registered_builtin_args(reference, &tool_name, &tool, &mut args);
549        }
550        self.check_permissions_for_resolved(call, &tool_name, &args, ctx)
551            .await
552    }
553
554    async fn check_permissions_for_exact(
555        &self,
556        call: &ToolCall,
557        execution_name: &str,
558        ctx: &ToolExecutionContext<'_>,
559    ) -> Result<Option<ToolOutcome>, ToolError> {
560        let tool = self
561            .registry
562            .get(execution_name)
563            .ok_or_else(|| ToolError::NotFound(format!("Tool '{}' not found", execution_name)))?;
564        let mut args = if let Some(pre_parsed) = ctx.pre_parsed_args {
565            pre_parsed.clone()
566        } else {
567            parse_tool_args_best_effort(&call.function.arguments).0
568        };
569        self.normalize_registered_builtin_args(
570            call.function.name.trim(),
571            execution_name,
572            &tool,
573            &mut args,
574        );
575        self.check_permissions_for_resolved(call, execution_name, &args, ctx)
576            .await
577    }
578
579    async fn check_permissions_for_resolved(
580        &self,
581        call: &ToolCall,
582        execution_name: &str,
583        resolved_args: &serde_json::Value,
584        ctx: &ToolExecutionContext<'_>,
585    ) -> Result<Option<ToolOutcome>, ToolError> {
586        let tool_name = execution_name.to_string();
587        let args = resolved_args.clone();
588        if ctx.auto_approve_permissions && tool_name.eq_ignore_ascii_case("request_permissions") {
589            return Err(ToolError::Execution(
590                "Auto mode cannot request expanded permissions; operate within existing hard boundaries"
591                    .to_string(),
592            ));
593        }
594        if ctx.plan_read_only && !crate::orchestrator::plan_mode_allows_tool(&tool_name) {
595            return Err(ToolError::Execution(format!(
596                "Plan mode: {tool_name} operation blocked"
597            )));
598        }
599        let Some(permission_checker) = &self.permission_checker else {
600            return Ok(None);
601        };
602        let hook_permission_override = crate::current_hook_permission_override(&call.id);
603
604        if let Some(contexts) =
605            check_permissions(&tool_name, &args).map_err(permission_error_to_tool_error)?
606        {
607            let proactive_permission_request =
608                tool_name.eq_ignore_ascii_case("request_permissions");
609            for context in contexts {
610                let resource = context.resource.clone();
611                let operation_summary = context.operation_description.clone();
612                let risk_level = context.risk_level();
613                let permission_type = context.permission_type;
614                let platform_hard_deny = permission_checker.hard_deny_reason(&context);
615                let config = permission_checker.permission_config();
616                let proxy = crate::approval::current_approval_proxy();
617                let request = if let Some(config) = config.as_ref() {
618                    if proactive_permission_request && proxy.is_some() {
619                        return Err(ToolError::Execution(
620                            "request_permissions requires the local typed decision protocol; a boolean approval relay cannot create remembered authority"
621                                .to_string(),
622                        ));
623                    }
624                    // A boolean approval relay can only honor one-shot choices.
625                    // Interactive local sessions support all typed scopes; the
626                    // evaluator omits workspace when no stable identity is known.
627                    let mut supported_decisions = if proxy.is_some() {
628                        crate::permission::PermissionRequest::forced_decisions()
629                    } else {
630                        crate::permission::PermissionRequest::ordinary_decisions(true)
631                    };
632                    if proactive_permission_request {
633                        // AllowOnce is bound to the request_permissions call,
634                        // not the later target operation, so offering it would
635                        // falsely claim authority was granted. Remembered
636                        // scopes remain exact matcher-bound and are replay-safe.
637                        supported_decisions.retain(|decision| {
638                            *decision != crate::permission::PermissionDecisionKind::AllowOnce
639                        });
640                    }
641                    // Workspace-scoped policy is an authority boundary. Tool
642                    // arguments are model-controlled resources and must never
643                    // choose that scope identity; only the workspace registered
644                    // for this stable session may enable AllowWorkspace.
645                    let workspace_path = ctx
646                        .session_id
647                        .and_then(|session_id| config.session_workspace(session_id));
648                    match config.evaluate(crate::permission::PermissionEvaluation {
649                        request_id: call.id.clone(),
650                        session_id: ctx.session_id.unwrap_or_default().to_string(),
651                        workspace_path,
652                        tool_name: tool_name.clone(),
653                        tool_args: args.clone(),
654                        permission_type,
655                        resource: resource.clone(),
656                        operation_summary: operation_summary.clone(),
657                        risk_level,
658                        bypass_requested: ctx.bypass_permissions,
659                        auto_approve_requested: ctx.auto_approve_permissions,
660                        platform_hard_deny,
661                        consume_once: true,
662                        supported_decisions,
663                    }) {
664                        crate::permission::PermissionOutcome::Allow { .. } => continue,
665                        crate::permission::PermissionOutcome::Deny { reason, .. } => {
666                            return Err(ToolError::Execution(reason.message));
667                        }
668                        crate::permission::PermissionOutcome::Ask(request)
669                            if matches!(
670                                hook_permission_override,
671                                Some(crate::HookPermissionOverride::Allow)
672                            ) && !proactive_permission_request
673                                && request.reason_code
674                                    != crate::permission::PermissionReasonCode::HardDangerous =>
675                        {
676                            continue;
677                        }
678                        crate::permission::PermissionOutcome::Ask(request) => request,
679                    }
680                } else {
681                    if proactive_permission_request {
682                        return Err(ToolError::Execution(
683                            "request_permissions requires a typed PermissionConfig and cannot fall back to a display-string approval"
684                                .to_string(),
685                        ));
686                    }
687                    // Compatibility path for custom checkers that do not expose a
688                    // typed config. It remains one-shot only and fail-closed.
689                    if let Some(reason) = platform_hard_deny {
690                        return Err(ToolError::Execution(reason));
691                    }
692                    let force_ask =
693                        permission_checker.requires_forced_confirmation(&tool_name, &args);
694                    let hook_allows = matches!(
695                        hook_permission_override,
696                        Some(crate::HookPermissionOverride::Allow)
697                    );
698                    if ctx.auto_approve_permissions
699                        || ((ctx.bypass_permissions || hook_allows) && !force_ask)
700                    {
701                        continue;
702                    }
703                    let decision = if force_ask {
704                        permission_checker.check_or_request_forced(context).await
705                    } else if let Some(session_id) = ctx.session_id {
706                        permission_checker
707                            .check_or_request_for_session(session_id, context)
708                            .await
709                    } else {
710                        permission_checker.check_or_request(context).await
711                    };
712                    match decision {
713                        Ok(true) => continue,
714                        Ok(false) => {
715                            return Err(ToolError::Execution(format!(
716                                "Permission denied for: {}",
717                                resource
718                            )));
719                        }
720                        Err(PermissionError::ConfirmationRequired { .. }) => {
721                            crate::permission::PermissionRequest {
722                                request_id: call.id.clone(),
723                                request_generation:
724                                    crate::permission::PermissionRequest::fresh_generation(),
725                                session_id: ctx.session_id.unwrap_or_default().to_string(),
726                                workspace_path: None,
727                                tool_name: tool_name.clone(),
728                                permission_type,
729                                resource: resource.clone(),
730                                operation_summary: operation_summary.clone(),
731                                risk_level,
732                                reason_code: if force_ask {
733                                    crate::permission::PermissionReasonCode::ConfiguredAlwaysAsk
734                                } else {
735                                    crate::permission::PermissionReasonCode::RiskThreshold
736                                },
737                                effective_mode: bamboo_config::settings::PermissionMode::Default,
738                                bypass_requested: ctx.bypass_permissions,
739                                auto_approve_requested: ctx.auto_approve_permissions,
740                                policy_revision: 0,
741                                matched_rule: None,
742                                allowed_decisions:
743                                    crate::permission::PermissionRequest::forced_decisions(),
744                                suggested_matchers: crate::permission::conservative_matchers(
745                                    permission_type,
746                                    &resource,
747                                ),
748                            }
749                        }
750                        Err(other) => return Err(permission_error_to_tool_error(other)),
751                    }
752                };
753
754                // A worker/external relay gets the same typed request but only
755                // one-shot decisions are advertised until its protocol supports
756                // a stronger scope. No boolean downgrade can create a grant.
757                if let Some(proxy) = proxy {
758                    let approved = proxy
759                        .request_approval(crate::approval::ApprovalAsk {
760                            tool_name: tool_name.clone(),
761                            permission: permission_type.description().to_string(),
762                            resource: resource.clone(),
763                            permission_request: Some(request.clone()),
764                        })
765                        .await;
766                    if approved {
767                        continue;
768                    }
769                    return Err(ToolError::Execution(format!(
770                        "Permission denied by host for: {}",
771                        resource
772                    )));
773                }
774
775                // Interactive sessions pause through the legacy question shape
776                // while carrying the complete typed request alongside it.
777                if let Some(tx) = ctx.event_tx {
778                    let _ = tx
779                        .send(bamboo_agent_core::AgentEvent::ToolApprovalRequested {
780                            tool_call_id: call.id.clone(),
781                            tool_name: tool_name.clone(),
782                            parameters: args.clone(),
783                        })
784                        .await;
785
786                    let question = format!(
787                        "**Permission required**\n\nThe `{}` tool needs approval to {} on:\n\n`{}`",
788                        tool_name,
789                        permission_type.description(),
790                        resource
791                    );
792                    if let Some(config) = config {
793                        config.register_pending_request(request.clone());
794                    }
795                    let payload = serde_json::json!({
796                        "status": "awaiting_permission_approval",
797                        "question": question,
798                        "permission_type": permission_type,
799                        "resource": resource,
800                        "options": ["Approve", "Deny"],
801                        "allow_custom": false,
802                        "permission_request": request,
803                    });
804                    return Ok(Some(ToolOutcome::Completed(ToolResult {
805                        success: true,
806                        result: payload.to_string(),
807                        display_preference: Some("request_permissions".to_string()),
808                        images: Vec::new(),
809                    })));
810                }
811
812                return Err(ToolError::Execution(format!(
813                    "Permission approval required for: {}",
814                    resource
815                )));
816            }
817        }
818
819        Ok(None)
820    }
821
822    fn list_tools(&self) -> Vec<ToolSchema> {
823        self.registry.list_tools()
824    }
825
826    fn owns_exact_tool(&self, tool_name: &str) -> bool {
827        self.registry.contains(tool_name)
828    }
829
830    fn tool_mutability(&self, tool_name: &str) -> crate::ToolMutability {
831        let resolved = resolve_registered_tool_name(&self.registry, tool_name);
832        resolved
833            .as_deref()
834            .and_then(|name| self.registry.get(name))
835            .map(|tool| tool.classify(&serde_json::Value::Null).mutability)
836            .unwrap_or_else(|| crate::classify_tool(&canonical_tool_name(tool_name)))
837    }
838
839    fn call_mutability(&self, call: &ToolCall) -> crate::ToolMutability {
840        self.call_parallel_classification(call).0
841    }
842
843    fn tool_concurrency_safe(&self, tool_name: &str) -> bool {
844        let resolved = resolve_registered_tool_name(&self.registry, tool_name);
845        resolved
846            .as_deref()
847            .and_then(|name| self.registry.get(name))
848            .map(|tool| tool.classify(&serde_json::Value::Null).parallel_safe)
849            .unwrap_or_else(|| self.tool_mutability(tool_name) == crate::ToolMutability::ReadOnly)
850    }
851
852    fn call_concurrency_safe(&self, call: &ToolCall) -> bool {
853        self.call_parallel_classification(call).1
854    }
855
856    fn call_parallel_classification(&self, call: &ToolCall) -> (crate::ToolMutability, bool) {
857        // One args-aware `classify` returns the (mutability, parallel_safe) pair
858        // with a single arg parse — the collapse of the former
859        // `call_mutability`/`call_concurrency_safe` pair.
860        let reference = call.function.name.trim();
861        let resolved = resolve_registered_tool_name(&self.registry, reference);
862        let mut args = bamboo_agent_core::parse_tool_args_best_effort(&call.function.arguments).0;
863        match resolved.as_deref().and_then(|execution_name| {
864            self.registry
865                .get(execution_name)
866                .map(|tool| (execution_name, tool))
867        }) {
868            Some((execution_name, tool)) => {
869                self.normalize_registered_builtin_args(reference, execution_name, &tool, &mut args);
870                let class = tool.classify(&args);
871                (class.mutability, class.parallel_safe)
872            }
873            None => (
874                self.tool_mutability(reference),
875                self.tool_concurrency_safe(reference),
876            ),
877        }
878    }
879}
880
881/// Builder for constructing a BuiltinToolExecutor with custom tool configurations
882pub struct BuiltinToolExecutorBuilder {
883    registry: ToolRegistry,
884    permission_checker: Option<Arc<dyn PermissionChecker>>,
885    framework_builtin_tools: BTreeMap<String, Arc<dyn Tool>>,
886    tool_event_publisher: Arc<dyn ToolEventPublisher>,
887}
888
889impl BuiltinToolExecutorBuilder {
890    /// Creates a new builder with no tools registered
891    pub fn new() -> Self {
892        Self {
893            registry: ToolRegistry::new(),
894            permission_checker: None,
895            framework_builtin_tools: BTreeMap::new(),
896            tool_event_publisher: BuiltinToolExecutor::default_tool_event_publisher(),
897        }
898    }
899
900    /// Registers all default built-in tools
901    pub fn with_default_tools(mut self) -> Self {
902        self.framework_builtin_tools
903            .extend(BuiltinToolExecutor::register_builtin_tools(
904                &self.registry,
905                None,
906            ));
907        self
908    }
909
910    /// Registers a specific filesystem tool by name
911    pub fn with_filesystem_tool(mut self, name: &str) -> Result<Self, ToolError> {
912        let (name, tool) = match name {
913            "Read" => {
914                BuiltinToolExecutor::register_tracked_builtin(&self.registry, ReadTool::new())?
915            }
916            "Write" => {
917                BuiltinToolExecutor::register_tracked_builtin(&self.registry, WriteTool::new())?
918            }
919            // apply_patch is now an alias for Edit
920            "Edit" | "apply_patch" => {
921                BuiltinToolExecutor::register_tracked_builtin(&self.registry, EditTool::new())?
922            }
923            "NotebookEdit" => BuiltinToolExecutor::register_tracked_builtin(
924                &self.registry,
925                NotebookEditTool::new(),
926            )?,
927            _ => return Err(ToolError::NotFound(format!("Unknown tool: {}", name))),
928        };
929        self.framework_builtin_tools.insert(name, tool);
930        Ok(self)
931    }
932
933    /// Registers a specific command tool by name
934    pub fn with_command_tool(mut self, name: &str) -> Result<Self, ToolError> {
935        if name == "Bash" {
936            let (name, tool) =
937                BuiltinToolExecutor::register_tracked_builtin(&self.registry, BashTool::new())?;
938            self.framework_builtin_tools.insert(name, tool);
939            return Ok(self);
940        }
941        match name {
942            "BashOutput" => self.registry.register(BashOutputTool::new()),
943            "KillShell" => self.registry.register(KillShellTool::new()),
944            "Task" => self.registry.register(TaskTool::new()),
945            _ => return Err(ToolError::NotFound(format!("Unknown tool: {}", name))),
946        }
947        .map_err(|e| ToolError::Execution(e.to_string()))?;
948        Ok(self)
949    }
950
951    /// Registers a custom tool
952    pub fn with_tool<T: Tool + 'static>(self, tool: T) -> Result<Self, ToolError> {
953        self.registry
954            .register(tool)
955            .map_err(|e| ToolError::Execution(e.to_string()))?;
956        Ok(self)
957    }
958
959    /// Sets a permission checker for this executor
960    pub fn with_permission_checker(mut self, checker: Arc<dyn PermissionChecker>) -> Self {
961        self.permission_checker = Some(checker);
962        self
963    }
964
965    /// Sets the instance-local tool-event publisher.
966    pub fn with_tool_event_publisher(mut self, publisher: Arc<dyn ToolEventPublisher>) -> Self {
967        self.tool_event_publisher = publisher;
968        self
969    }
970
971    /// Builds the executor
972    pub fn build(self) -> BuiltinToolExecutor {
973        BuiltinToolExecutor {
974            registry: self.registry,
975            permission_checker: self.permission_checker,
976            framework_builtin_tools: self.framework_builtin_tools,
977            tool_event_publisher: self.tool_event_publisher,
978        }
979    }
980}
981
982impl Default for BuiltinToolExecutorBuilder {
983    fn default() -> Self {
984        Self::new()
985    }
986}
987
988#[cfg(test)]
989mod tests {
990    use super::*;
991    use bamboo_agent_core::AgentEvent;
992    use bamboo_agent_core::FunctionCall;
993    use bamboo_agent_core::ToolCtx;
994    use bamboo_agent_core::ToolExecutionContext;
995    use bamboo_domain::tool_names::{normalize_tool_ref, BUILTIN_TOOL_NAMES};
996    use bamboo_plugin_protocol::{
997        FileChangedV1, InMemoryToolEventRecorder, ToolEventContextV1, ToolEventPublishError,
998        ToolEventV1, MAX_TOOL_EVENT_PATH_BYTES,
999    };
1000    use serde_json::json;
1001    use std::sync::atomic::{AtomicUsize, Ordering};
1002    use std::sync::Arc;
1003    use tokio::fs;
1004    use tokio::sync::mpsc;
1005
1006    use crate::tools::WriteTool;
1007
1008    fn make_tool_call(name: &str, args: serde_json::Value) -> ToolCall {
1009        make_tool_call_with_id("call_1", name, args)
1010    }
1011
1012    #[tokio::test]
1013    async fn oversized_write_is_rejected_before_creating_a_file() {
1014        let dir = tempfile::tempdir().unwrap();
1015        let file = dir.path().join("oversized.txt");
1016        let call = make_tool_call(
1017            "write_file",
1018            json!({
1019                "path": file,
1020                "content": "x".repeat(1024 * 1024),
1021            }),
1022        );
1023        let error = BuiltinToolExecutor::new().execute(&call).await.unwrap_err();
1024        assert!(matches!(error, ToolError::InvalidArguments(_)));
1025        assert!(!file.exists());
1026    }
1027
1028    fn make_tool_call_with_id(id: &str, name: &str, args: serde_json::Value) -> ToolCall {
1029        ToolCall {
1030            id: id.to_string(),
1031            tool_type: "function".to_string(),
1032            function: FunctionCall {
1033                name: name.to_string(),
1034                arguments: args.to_string(),
1035            },
1036        }
1037    }
1038
1039    fn tool_event_context<'a>(
1040        call: &'a ToolCall,
1041        session_id: Option<&'a str>,
1042        root_session_id: Option<&'a str>,
1043    ) -> ToolExecutionContext<'a> {
1044        ToolExecutionContext {
1045            executing_supervisor: None,
1046            session_id,
1047            root_session_id,
1048            tool_call_id: &call.id,
1049            event_tx: None,
1050            available_tool_schemas: None,
1051            bypass_permissions: false,
1052            auto_approve_permissions: false,
1053            plan_read_only: false,
1054            can_async_resume: false,
1055            bash_completion_sink: None,
1056            pre_parsed_args: None,
1057        }
1058    }
1059
1060    fn assert_single_file_changed(
1061        recorder: &InMemoryToolEventRecorder,
1062        session_id: &str,
1063        root_session_id: &str,
1064        tool_name: &str,
1065        tool_call_id: &str,
1066        path: &str,
1067    ) {
1068        let events = recorder.try_snapshot().expect("snapshot tool events");
1069        assert_eq!(
1070            events.len(),
1071            1,
1072            "successful mutation must emit exactly once"
1073        );
1074        let event = &events[0];
1075        assert_eq!(event.context.session_id, session_id);
1076        assert_eq!(event.context.root_session_id, root_session_id);
1077        assert_eq!(event.context.tool_name, tool_name);
1078        assert_eq!(event.context.tool_call_id, tool_call_id);
1079        assert_eq!(
1080            event
1081                .file_changed_data()
1082                .expect("known file_changed event")
1083                .expect("valid file_changed payload")
1084                .path,
1085            path
1086        );
1087    }
1088
1089    fn seed_event(call_id: &str) -> ToolEventV1 {
1090        ToolEventV1::file_changed(
1091            ToolEventContextV1::bounded("seed-session", "seed-root-session", "Write", call_id)
1092                .unwrap(),
1093            FileChangedV1::bounded("/seed/file.txt").unwrap(),
1094        )
1095        .unwrap()
1096    }
1097
1098    fn make_tool_call_with_raw_args(name: &str, raw_args: &str) -> ToolCall {
1099        ToolCall {
1100            id: "call_1".to_string(),
1101            tool_type: "function".to_string(),
1102            function: FunctionCall {
1103                name: name.to_string(),
1104                arguments: raw_args.to_string(),
1105            },
1106        }
1107    }
1108
1109    struct ReturningPublisher(ToolEventPublishError);
1110
1111    impl ToolEventPublisher for ReturningPublisher {
1112        fn try_publish(&self, _event: ToolEventV1) -> Result<(), ToolEventPublishError> {
1113            Err(self.0.clone())
1114        }
1115    }
1116
1117    struct IsEnabledPanicPublisher;
1118
1119    impl ToolEventPublisher for IsEnabledPanicPublisher {
1120        fn is_enabled(&self) -> bool {
1121            panic!("is_enabled publisher panic")
1122        }
1123
1124        fn try_publish(&self, _event: ToolEventV1) -> Result<(), ToolEventPublishError> {
1125            unreachable!("disabled publisher must not receive an event")
1126        }
1127    }
1128
1129    struct TryPublishPanicPublisher;
1130
1131    impl ToolEventPublisher for TryPublishPanicPublisher {
1132        fn try_publish(&self, _event: ToolEventV1) -> Result<(), ToolEventPublishError> {
1133            panic!("try_publish publisher panic")
1134        }
1135    }
1136
1137    struct StubWriteTool {
1138        success: bool,
1139    }
1140
1141    #[async_trait]
1142    impl Tool for StubWriteTool {
1143        fn name(&self) -> &str {
1144            "Write"
1145        }
1146
1147        fn description(&self) -> &str {
1148            "test-only custom tool that deliberately spoofs Write"
1149        }
1150
1151        fn parameters_schema(&self) -> serde_json::Value {
1152            json!({"type": "object", "properties": {"file_path": {"type": "string"}}})
1153        }
1154
1155        async fn invoke(
1156            &self,
1157            _args: serde_json::Value,
1158            _ctx: ToolCtx,
1159        ) -> Result<ToolOutcome, ToolError> {
1160            Ok(ToolOutcome::Completed(ToolResult {
1161                success: self.success,
1162                result: "stub-write-result".to_string(),
1163                display_preference: None,
1164                images: Vec::new(),
1165            }))
1166        }
1167    }
1168
1169    fn marked_stub_write_executor(
1170        success: bool,
1171        publisher: Arc<dyn ToolEventPublisher>,
1172    ) -> BuiltinToolExecutor {
1173        let registry = ToolRegistry::new();
1174        let tool: Arc<dyn Tool> = Arc::new(StubWriteTool { success });
1175        registry
1176            .register_shared(tool.clone())
1177            .expect("register stub Write");
1178        BuiltinToolExecutor {
1179            registry,
1180            permission_checker: None,
1181            framework_builtin_tools: BTreeMap::from([("Write".to_string(), tool)]),
1182            tool_event_publisher: publisher,
1183        }
1184    }
1185
1186    async fn assert_real_write_succeeds_with_publisher(
1187        publisher: Arc<dyn ToolEventPublisher>,
1188        label: &str,
1189    ) {
1190        let dir = tempfile::tempdir().unwrap();
1191        let path = dir.path().join(format!("publisher-{label}.txt"));
1192        let call = make_tool_call_with_id(
1193            &format!("publisher-{label}"),
1194            "Write",
1195            json!({"file_path": path, "content": label}),
1196        );
1197        let executor = BuiltinToolExecutorBuilder::new()
1198            .with_filesystem_tool("Write")
1199            .expect("register built-in Write")
1200            .with_tool_event_publisher(publisher)
1201            .build();
1202
1203        let result = executor
1204            .execute_with_context(
1205                &call,
1206                tool_event_context(
1207                    &call,
1208                    Some("publisher-session"),
1209                    Some("publisher-root-session"),
1210                ),
1211            )
1212            .await
1213            .expect("publisher behavior must not turn tool success into an error");
1214
1215        assert!(
1216            result.success,
1217            "publisher must not alter ToolResult.success"
1218        );
1219        assert_eq!(fs::read_to_string(path).await.unwrap(), label);
1220    }
1221
1222    fn make_executor(
1223        permission_checker: Option<Arc<dyn PermissionChecker>>,
1224    ) -> BuiltinToolExecutor {
1225        let builder = BuiltinToolExecutorBuilder::new()
1226            .with_tool(WriteTool::new())
1227            .expect("register Write tool");
1228
1229        let builder = match permission_checker {
1230            Some(checker) => builder.with_permission_checker(checker),
1231            None => builder,
1232        };
1233
1234        builder.build()
1235    }
1236
1237    async fn permission_request_payload(
1238        executor: &BuiltinToolExecutor,
1239        session_id: &str,
1240        args: serde_json::Value,
1241    ) -> serde_json::Value {
1242        let (event_tx, _event_rx) = mpsc::channel(4);
1243        let call = make_tool_call("Write", args);
1244        let ctx = ToolExecutionContext {
1245            executing_supervisor: None,
1246            session_id: Some(session_id),
1247            root_session_id: None,
1248            tool_call_id: &call.id,
1249            event_tx: Some(&event_tx),
1250            available_tool_schemas: None,
1251            bypass_permissions: false,
1252            auto_approve_permissions: false,
1253            plan_read_only: false,
1254            can_async_resume: false,
1255            bash_completion_sink: None,
1256            pre_parsed_args: None,
1257        };
1258        let result = executor
1259            .execute_with_context(&call, ctx)
1260            .await
1261            .expect("interactive permission gate should pause");
1262        serde_json::from_str(&result.result).expect("typed permission payload")
1263    }
1264
1265    struct RecordingApprovalProxy {
1266        requests: Arc<AtomicUsize>,
1267        approve: bool,
1268    }
1269
1270    #[async_trait]
1271    impl crate::approval::ApprovalProxy for RecordingApprovalProxy {
1272        async fn request_approval(&self, _ask: crate::approval::ApprovalAsk) -> bool {
1273            self.requests.fetch_add(1, Ordering::SeqCst);
1274            self.approve
1275        }
1276    }
1277
1278    #[test]
1279    fn test_normalize_tool_ref_accepts_claude_style_names() {
1280        assert_eq!(
1281            normalize_tool_ref("default::Bash"),
1282            Some("Bash".to_string())
1283        );
1284    }
1285
1286    #[test]
1287    fn test_normalize_tool_ref_accepts_legacy_camel_aliases() {
1288        assert_eq!(
1289            normalize_tool_ref("default::fileExists"),
1290            Some("FileExists".to_string())
1291        );
1292        assert_eq!(
1293            normalize_tool_ref("default::getCurrentDir"),
1294            Some("GetCurrentDir".to_string())
1295        );
1296        assert_eq!(
1297            normalize_tool_ref("default::getFileInfo"),
1298            Some("GetFileInfo".to_string())
1299        );
1300        assert_eq!(
1301            normalize_tool_ref("default::setWorkspace"),
1302            Some("SetWorkspace".to_string())
1303        );
1304        assert_eq!(
1305            normalize_tool_ref("default::sleep"),
1306            Some("Sleep".to_string())
1307        );
1308    }
1309
1310    #[test]
1311    fn test_normalize_tool_ref_accepts_legacy_snake_case_aliases() {
1312        assert_eq!(
1313            normalize_tool_ref("default::execute_command"),
1314            Some("Bash".to_string())
1315        );
1316        assert_eq!(
1317            normalize_tool_ref("default::file_exists"),
1318            Some("FileExists".to_string())
1319        );
1320        assert_eq!(
1321            normalize_tool_ref("default::get_current_dir"),
1322            Some("GetCurrentDir".to_string())
1323        );
1324        assert_eq!(
1325            normalize_tool_ref("default::get_file_info"),
1326            Some("GetFileInfo".to_string())
1327        );
1328        assert_eq!(
1329            normalize_tool_ref("default::list_directory"),
1330            Some("Glob".to_string())
1331        );
1332        assert_eq!(
1333            normalize_tool_ref("default::memory_note"),
1334            Some("memory_note".to_string())
1335        );
1336        assert_eq!(
1337            normalize_tool_ref("default::read_file"),
1338            Some("Read".to_string())
1339        );
1340        assert_eq!(
1341            normalize_tool_ref("default::set_workspace"),
1342            Some("SetWorkspace".to_string())
1343        );
1344        assert_eq!(
1345            normalize_tool_ref("default::write_file"),
1346            Some("Write".to_string())
1347        );
1348    }
1349
1350    #[test]
1351    fn test_normalize_tool_ref_accepts_spawn_task_aliases() {
1352        for alias in [
1353            "default::spawn_session",
1354            "default::sub_session",
1355            "default::sub_task",
1356            "default::team_agent",
1357            "default::child_session",
1358        ] {
1359            assert_eq!(normalize_tool_ref(alias), Some("SubAgent".to_string()));
1360        }
1361    }
1362
1363    #[test]
1364    fn test_normalize_tool_ref_accepts_server_overlay_tools() {
1365        assert_eq!(normalize_tool_ref("compress_context"), None);
1366        assert_eq!(
1367            normalize_tool_ref("default::read_skill_resource"),
1368            Some("read_skill_resource".to_string())
1369        );
1370    }
1371
1372    #[tokio::test]
1373    async fn test_executor_accepts_legacy_read_file_path_argument() {
1374        let dir = tempfile::tempdir().unwrap();
1375        let file_path = dir.path().join("legacy-read.txt");
1376        fs::write(&file_path, "legacy read content").await.unwrap();
1377
1378        let executor = BuiltinToolExecutor::new();
1379        let call = make_tool_call("read_file", json!({"path": file_path}));
1380
1381        let result = executor.execute(&call).await.unwrap();
1382        assert!(result.success);
1383        assert!(result.result.contains("legacy read content"));
1384    }
1385
1386    #[tokio::test]
1387    async fn test_executor_accepts_legacy_list_directory_without_pattern() {
1388        let dir = tempfile::tempdir().unwrap();
1389        let file_path = dir.path().join("legacy-list.txt");
1390        fs::write(&file_path, "legacy list content").await.unwrap();
1391
1392        let executor = BuiltinToolExecutor::new();
1393        let call = make_tool_call("list_directory", json!({"path": dir.path()}));
1394
1395        let result = executor.execute(&call).await.unwrap();
1396        assert!(result.success);
1397        assert!(result.result.contains("legacy-list.txt"));
1398    }
1399
1400    #[tokio::test]
1401    async fn test_executor_accepts_canonical_read_with_path_argument() {
1402        let dir = tempfile::tempdir().unwrap();
1403        let file_path = dir.path().join("canonical-read.txt");
1404        fs::write(&file_path, "canonical read content")
1405            .await
1406            .unwrap();
1407
1408        let executor = BuiltinToolExecutor::new();
1409        let call = make_tool_call("Read", json!({"path": file_path}));
1410
1411        let result = executor.execute(&call).await.unwrap();
1412        assert!(result.success);
1413        assert!(result.result.contains("canonical read content"));
1414
1415        let namespaced = make_tool_call("default::Read", json!({"path": file_path}));
1416        let result = executor.execute(&namespaced).await.unwrap();
1417        assert!(result.success);
1418        assert!(result.result.contains("canonical read content"));
1419    }
1420
1421    #[tokio::test]
1422    async fn test_executor_accepts_canonical_glob_without_pattern_when_path_present() {
1423        let dir = tempfile::tempdir().unwrap();
1424        let file_path = dir.path().join("canonical-list.txt");
1425        fs::write(&file_path, "canonical list content")
1426            .await
1427            .unwrap();
1428
1429        let executor = BuiltinToolExecutor::new();
1430        let call = make_tool_call("Glob", json!({"path": dir.path()}));
1431
1432        let result = executor.execute(&call).await.unwrap();
1433        assert!(result.success);
1434        assert!(result.result.contains("canonical-list.txt"));
1435    }
1436
1437    #[test]
1438    fn test_executor_workspace_mutability_depends_on_path_argument() {
1439        let executor = BuiltinToolExecutor::new();
1440        let get_call = make_tool_call("Workspace", json!({}));
1441        let set_call = make_tool_call("Workspace", json!({"path": "/tmp"}));
1442
1443        assert_eq!(
1444            executor.call_mutability(&get_call),
1445            crate::ToolMutability::ReadOnly
1446        );
1447        assert!(executor.call_concurrency_safe(&get_call));
1448
1449        assert_eq!(
1450            executor.call_mutability(&set_call),
1451            crate::ToolMutability::Mutating
1452        );
1453        assert!(!executor.call_concurrency_safe(&set_call));
1454    }
1455
1456    #[test]
1457    fn call_parallel_classification_matches_individual_methods() {
1458        // Regression guard for the issue #17 perf refactor: the combined
1459        // `call_parallel_classification` (which parses args once) must return the
1460        // exact same (mutability, concurrency_safe) pair as calling
1461        // `call_mutability` and `call_concurrency_safe` separately (which each
1462        // parse args). Covers a read-only tool, mutating tools, and an
1463        // args-aware tool (Workspace get vs set) so every branch of the
1464        // single-parse override is exercised.
1465        let executor = BuiltinToolExecutor::new();
1466        let cases: &[(&str, serde_json::Value)] = &[
1467            ("Read", json!({})),
1468            ("Grep", json!({"pattern": "x"})),
1469            (
1470                "Write",
1471                json!({"file_path": "/tmp/par_cls.txt", "content": "y"}),
1472            ),
1473            ("Bash", json!({"command": "echo hi"})),
1474            ("Workspace", json!({})),
1475            ("Workspace", json!({"path": "/tmp"})),
1476        ];
1477
1478        for (name, args) in cases {
1479            let call = make_tool_call(name, args.clone());
1480            let expected_mutability = executor.call_mutability(&call);
1481            let expected_concurrency = executor.call_concurrency_safe(&call);
1482            let (mutability, concurrency) = executor.call_parallel_classification(&call);
1483            assert_eq!(
1484                mutability, expected_mutability,
1485                "mutability mismatch for {name} ({args})"
1486            );
1487            assert_eq!(
1488                concurrency, expected_concurrency,
1489                "concurrency mismatch for {name} ({args})"
1490            );
1491        }
1492    }
1493
1494    #[test]
1495    fn list_tools_snapshot_is_stable_across_calls() {
1496        // The per-round schema cache (issue #17 Part A) assumes the executor's
1497        // `list_tools()` is stable within a round: a snapshot taken once must
1498        // equal a fresh call. Guards that invariant so caching the set for the
1499        // duration of a round can't serve a stale or filtered view.
1500        let executor = BuiltinToolExecutor::new();
1501        let first: Vec<String> = executor
1502            .list_tools()
1503            .into_iter()
1504            .map(|s| s.function.name)
1505            .collect();
1506        let second: Vec<String> = executor
1507            .list_tools()
1508            .into_iter()
1509            .map(|s| s.function.name)
1510            .collect();
1511        assert!(!first.is_empty(), "builtin executor should expose tools");
1512        assert_eq!(
1513            first, second,
1514            "list_tools() must be deterministic per round"
1515        );
1516    }
1517
1518    #[tokio::test]
1519    async fn test_executor_recovers_truncated_json_arguments() {
1520        let dir = tempfile::tempdir().unwrap();
1521        let path = dir.path().join("recovered-write.txt");
1522
1523        // Missing closing brace simulates EOF while parsing an object.
1524        let malformed_args = format!(
1525            r#"{{"file_path":"{}","content":"recovered content""#,
1526            path.display()
1527        );
1528
1529        let executor = BuiltinToolExecutor::new();
1530        let call = make_tool_call_with_raw_args("Write", &malformed_args);
1531
1532        let result = executor
1533            .execute(&call)
1534            .await
1535            .expect("truncated JSON should be auto-repaired");
1536        assert!(result.success);
1537
1538        let written = fs::read_to_string(&path)
1539            .await
1540            .expect("file should be written");
1541        assert_eq!(written, "recovered content");
1542    }
1543
1544    #[test]
1545    fn test_normalize_tool_ref_rejects_unknown_tool() {
1546        assert_eq!(normalize_tool_ref("default::search"), None);
1547    }
1548
1549    #[test]
1550    fn test_executor_does_not_expose_legacy_tools() {
1551        let executor = BuiltinToolExecutor::new();
1552        let tool_names: Vec<String> = executor
1553            .list_tools()
1554            .into_iter()
1555            .map(|schema| schema.function.name)
1556            .collect();
1557
1558        for legacy in ["claude_code", "search_in_file", "search_in_project"] {
1559            assert!(!tool_names.iter().any(|name| name == legacy));
1560        }
1561    }
1562
1563    #[test]
1564    fn test_critical_tool_schemas_match_claude_shapes() {
1565        let executor = BuiltinToolExecutor::new();
1566        let tools = executor.list_tools();
1567
1568        let get_params = |name: &str| {
1569            tools
1570                .iter()
1571                .find(|tool| tool.function.name == name)
1572                .unwrap()
1573                .function
1574                .parameters
1575                .clone()
1576        };
1577
1578        let grep = get_params("Grep");
1579        assert_eq!(grep["required"], json!(["pattern"]));
1580        assert_eq!(
1581            grep["properties"]["output_mode"]["enum"],
1582            json!(["content", "files_with_matches", "count"])
1583        );
1584        assert!(grep["properties"]["-A"].is_object());
1585        assert!(grep["properties"]["-B"].is_object());
1586        assert!(grep["properties"]["-C"].is_object());
1587        assert!(grep["properties"]["-n"].is_object());
1588        assert!(grep["properties"]["-i"].is_object());
1589
1590        let edit = get_params("Edit");
1591        assert_eq!(edit["required"], json!(["file_path"]));
1592        assert_eq!(edit["properties"]["old_string"]["type"], "string");
1593        assert_eq!(edit["properties"]["new_string"]["type"], "string");
1594        assert_eq!(edit["properties"]["patch"]["type"], "string");
1595        assert_eq!(edit["properties"]["replace_all"]["type"], "boolean");
1596        assert!(edit.get("oneOf").is_none());
1597
1598        // apply_patch is now an alias for Edit – its schema is the Edit
1599        // schema, so we just verify that Edit includes the patch property.
1600        assert_eq!(edit["properties"]["patch"]["type"], "string");
1601        assert_eq!(edit["properties"]["line_number"]["type"], "integer");
1602
1603        let bash = get_params("Bash");
1604        assert_eq!(bash["required"], json!(["command"]));
1605        assert_eq!(bash["properties"]["run_in_background"]["type"], "boolean");
1606        assert_eq!(bash["properties"]["workdir"]["type"], "string");
1607
1608        let bash_output = get_params("BashOutput");
1609        assert_eq!(bash_output["required"], json!(["bash_id"]));
1610        assert_eq!(bash_output["properties"]["filter"]["type"], "string");
1611    }
1612
1613    #[test]
1614    fn test_tool_schemas_avoid_openai_forbidden_top_level_keywords() {
1615        let executor = BuiltinToolExecutor::new();
1616        let tools = executor.list_tools();
1617        let forbidden = ["oneOf", "anyOf", "allOf", "not", "enum"];
1618
1619        for tool in tools {
1620            let params = &tool.function.parameters;
1621            assert_eq!(
1622                params["type"], "object",
1623                "tool '{}' parameters must be a top-level object schema",
1624                tool.function.name
1625            );
1626            for key in forbidden {
1627                assert!(
1628                    params.get(key).is_none(),
1629                    "tool '{}' parameters contains forbidden top-level keyword '{}'",
1630                    tool.function.name,
1631                    key
1632                );
1633            }
1634        }
1635    }
1636
1637    #[test]
1638    fn test_executor_has_all_builtin_tools() {
1639        let executor = BuiltinToolExecutor::new();
1640        let tools = executor.list_tools();
1641
1642        assert_eq!(tools.len(), BUILTIN_TOOL_NAMES.len());
1643
1644        let tool_names: Vec<String> = tools.iter().map(|t| t.function.name.clone()).collect();
1645        for tool_name in BUILTIN_TOOL_NAMES {
1646            assert!(tool_names.contains(&tool_name.to_string()));
1647        }
1648    }
1649
1650    #[test]
1651    fn test_executor_builds_enhanced_prompt() {
1652        let executor = BuiltinToolExecutor::new();
1653        let prompt = executor.build_enhanced_prompt(GuideBuildContext::default());
1654        assert!(prompt.contains("## Tool Usage Guidelines"));
1655        assert!(prompt.contains("**Read**"));
1656    }
1657
1658    #[test]
1659    fn test_executor_builder_empty() {
1660        let executor = BuiltinToolExecutorBuilder::new().build();
1661        assert!(executor.list_tools().is_empty());
1662    }
1663
1664    #[test]
1665    fn test_executor_builder_with_default_tools() {
1666        let executor = BuiltinToolExecutorBuilder::new()
1667            .with_default_tools()
1668            .build();
1669        assert_eq!(executor.list_tools().len(), BUILTIN_TOOL_NAMES.len());
1670    }
1671
1672    #[test]
1673    fn test_executor_builder_with_specific_tool() {
1674        let executor = BuiltinToolExecutorBuilder::new()
1675            .with_filesystem_tool("Read")
1676            .unwrap()
1677            .build();
1678
1679        let tools = executor.list_tools();
1680        assert_eq!(tools.len(), 1);
1681        assert_eq!(tools[0].function.name, "Read");
1682    }
1683
1684    #[tokio::test]
1685    async fn test_executor_skips_permission_checks_without_checker() {
1686        let executor = make_executor(None);
1687        let path = "/tmp/executor_permission_none.txt";
1688        let _ = fs::remove_file(path).await;
1689
1690        let call = make_tool_call("Write", json!({"file_path": path, "content": "ok"}));
1691        let result = executor.execute(&call).await.expect("execute tool");
1692
1693        assert!(result.success);
1694        let _ = fs::remove_file(path).await;
1695    }
1696
1697    #[tokio::test]
1698    async fn test_executor_with_permission_checker_enforces_checks() {
1699        let checker = Arc::new(crate::permission::DenyDangerousPermissionChecker);
1700        let executor = make_executor(Some(checker));
1701        let path = "/tmp/executor_permission_denied.txt";
1702        let _ = fs::remove_file(path).await;
1703
1704        let call = make_tool_call("Write", json!({"file_path": path, "content": "nope"}));
1705        let result = executor.execute(&call).await;
1706
1707        assert!(matches!(result, Err(ToolError::Execution(_))));
1708        assert!(fs::metadata(path).await.is_err());
1709    }
1710
1711    #[tokio::test]
1712    async fn test_bypass_permissions_skips_checker() {
1713        // Model the worker side of a child whose parent bypass flag was inherited:
1714        // a production Bash tool under the production config evaluator must
1715        // execute an ordinary command directly. Even though both a parent
1716        // approval proxy and a human-event sink are installed, neither path may
1717        // be touched.
1718        let config = Arc::new(crate::permission::PermissionConfig::new());
1719        let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
1720        let executor = BuiltinToolExecutorBuilder::new()
1721            .with_tool(BashTool::new())
1722            .expect("register Bash tool")
1723            .with_permission_checker(checker)
1724            .build();
1725        let dir = tempfile::tempdir().unwrap();
1726        let path = dir.path().join("bypass_allows_bash.txt");
1727        let command = format!("printf ordinary > {}", path.display());
1728        let approval_requests = Arc::new(AtomicUsize::new(0));
1729        let proxy: Arc<dyn crate::approval::ApprovalProxy> = Arc::new(RecordingApprovalProxy {
1730            requests: approval_requests.clone(),
1731            approve: true,
1732        });
1733        let (event_tx, mut event_rx) = mpsc::channel(8);
1734
1735        let call = make_tool_call("Bash", json!({"command": command}));
1736        let ctx = ToolExecutionContext {
1737            executing_supervisor: None,
1738            session_id: Some("s-bypass"),
1739            root_session_id: None,
1740            tool_call_id: &call.id,
1741            event_tx: Some(&event_tx),
1742            available_tool_schemas: None,
1743            bypass_permissions: true,
1744            auto_approve_permissions: false,
1745            plan_read_only: false,
1746            can_async_resume: false,
1747            bash_completion_sink: None,
1748            pre_parsed_args: None,
1749        };
1750        let result = crate::approval::with_approval_proxy(
1751            Some(proxy),
1752            executor.execute_with_context(&call, ctx),
1753        )
1754        .await;
1755
1756        assert!(result.is_ok(), "bypass should allow the write: {result:?}");
1757        assert_eq!(fs::read_to_string(&path).await.unwrap(), "ordinary");
1758        assert_eq!(
1759            approval_requests.load(Ordering::SeqCst),
1760            0,
1761            "ordinary bypassed child command must not invoke the parent reviewer"
1762        );
1763        assert!(
1764            event_rx.try_recv().is_err(),
1765            "ordinary bypassed child command must not emit a human approval event"
1766        );
1767    }
1768
1769    #[tokio::test]
1770    async fn hook_allow_skips_configured_ask_for_exact_call() {
1771        let dir = tempfile::tempdir().unwrap();
1772        let path = dir.path().join("hook-allowed.txt");
1773        let path_str = path.to_str().unwrap().to_string();
1774        let config = Arc::new(crate::permission::PermissionConfig::new());
1775        config.set_ask_rules([format!("Write({}/**)", dir.path().to_str().unwrap())]);
1776        let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
1777        let executor = make_executor(Some(checker));
1778        let call = make_tool_call(
1779            "Write",
1780            json!({"file_path": path_str, "content": "allowed by hook"}),
1781        );
1782        let ctx = ToolExecutionContext {
1783            executing_supervisor: None,
1784            session_id: Some("s-hook-allow"),
1785            root_session_id: None,
1786            tool_call_id: &call.id,
1787            event_tx: None,
1788            available_tool_schemas: None,
1789            bypass_permissions: false,
1790            auto_approve_permissions: false,
1791            plan_read_only: false,
1792            can_async_resume: false,
1793            bash_completion_sink: None,
1794            pre_parsed_args: None,
1795        };
1796
1797        let result = crate::with_hook_permission_override(
1798            Some(crate::HookPermissionOverride::Allow),
1799            &call.id,
1800            executor.execute_with_context(&call, ctx),
1801        )
1802        .await;
1803
1804        assert!(
1805            result.is_ok(),
1806            "hook allow should skip ordinary ask: {result:?}"
1807        );
1808        assert_eq!(fs::read_to_string(path).await.unwrap(), "allowed by hook");
1809        assert_eq!(
1810            crate::current_hook_permission_override(&call.id),
1811            None,
1812            "the one-call override must not leak"
1813        );
1814    }
1815
1816    #[tokio::test]
1817    async fn hook_allow_cannot_skip_hard_dangerous_parent_review() {
1818        let config = Arc::new(crate::permission::PermissionConfig::new());
1819        let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
1820        let executor = BuiltinToolExecutorBuilder::new()
1821            .with_tool(BashTool::new())
1822            .expect("register Bash tool")
1823            .with_permission_checker(checker)
1824            .build();
1825        let dir = tempfile::tempdir().unwrap();
1826        let path = dir.path().join("hard-dangerous-must-not-run.txt");
1827        let command = format!("eval 'printf denied > {}'", path.display());
1828        let requests = Arc::new(AtomicUsize::new(0));
1829        let proxy: Arc<dyn crate::approval::ApprovalProxy> = Arc::new(RecordingApprovalProxy {
1830            requests: requests.clone(),
1831            approve: false,
1832        });
1833        let call = make_tool_call("Bash", json!({"command": command}));
1834        let ctx = ToolExecutionContext {
1835            executing_supervisor: None,
1836            session_id: Some("s-hook-hard-dangerous"),
1837            root_session_id: None,
1838            tool_call_id: &call.id,
1839            event_tx: None,
1840            available_tool_schemas: None,
1841            bypass_permissions: true,
1842            auto_approve_permissions: false,
1843            plan_read_only: false,
1844            can_async_resume: false,
1845            bash_completion_sink: None,
1846            pre_parsed_args: None,
1847        };
1848
1849        let result = crate::with_hook_permission_override(
1850            Some(crate::HookPermissionOverride::Allow),
1851            &call.id,
1852            crate::approval::with_approval_proxy(
1853                Some(proxy),
1854                executor.execute_with_context(&call, ctx),
1855            ),
1856        )
1857        .await;
1858
1859        assert!(
1860            matches!(result, Err(ToolError::Execution(ref message)) if message.contains("denied by host")),
1861            "hard-dangerous review must remain authoritative: {result:?}"
1862        );
1863        assert_eq!(requests.load(Ordering::SeqCst), 1);
1864        assert!(!path.exists());
1865    }
1866
1867    #[tokio::test]
1868    async fn hook_allow_cannot_skip_explicit_deny() {
1869        let dir = tempfile::tempdir().unwrap();
1870        let path = dir.path().join("explicit-deny.txt");
1871        let path_str = path.to_str().unwrap().to_string();
1872        let config = Arc::new(crate::permission::PermissionConfig::new());
1873        config.deny_scoped_session_permission(
1874            "s-hook-explicit-deny",
1875            crate::permission::PermissionType::WriteFile,
1876            path_str.clone(),
1877        );
1878        let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
1879        let executor = make_executor(Some(checker));
1880        let call = make_tool_call(
1881            "Write",
1882            json!({"file_path": path_str, "content": "must not be written"}),
1883        );
1884        let ctx = ToolExecutionContext {
1885            executing_supervisor: None,
1886            session_id: Some("s-hook-explicit-deny"),
1887            root_session_id: None,
1888            tool_call_id: &call.id,
1889            event_tx: None,
1890            available_tool_schemas: None,
1891            bypass_permissions: false,
1892            auto_approve_permissions: false,
1893            plan_read_only: false,
1894            can_async_resume: false,
1895            bash_completion_sink: None,
1896            pre_parsed_args: None,
1897        };
1898
1899        let result = crate::with_hook_permission_override(
1900            Some(crate::HookPermissionOverride::Allow),
1901            &call.id,
1902            executor.execute_with_context(&call, ctx),
1903        )
1904        .await;
1905
1906        assert!(
1907            matches!(result, Err(ToolError::Execution(ref message)) if message.contains("remembered session decision")),
1908            "explicit deny must remain authoritative: {result:?}"
1909        );
1910        assert!(!path.exists());
1911    }
1912
1913    #[tokio::test]
1914    async fn test_forced_ask_rule_overrides_bypass() {
1915        // A hard-dangerous Bash command must still traverse the worker's parent
1916        // approval proxy under bypass. The returned verdict is authoritative:
1917        // deny prevents execution, while approve lets the exact command run.
1918        let config = Arc::new(crate::permission::PermissionConfig::new());
1919        let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
1920        let executor = BuiltinToolExecutorBuilder::new()
1921            .with_tool(BashTool::new())
1922            .expect("register Bash tool")
1923            .with_permission_checker(checker)
1924            .build();
1925        let dir = tempfile::tempdir().unwrap();
1926        let denied_path = dir.path().join("forced-denied.txt");
1927        let denied_command = format!("eval 'printf denied > {}'", denied_path.display());
1928        let denied_requests = Arc::new(AtomicUsize::new(0));
1929        let deny_proxy: Arc<dyn crate::approval::ApprovalProxy> =
1930            Arc::new(RecordingApprovalProxy {
1931                requests: denied_requests.clone(),
1932                approve: false,
1933            });
1934
1935        let denied_call = make_tool_call("Bash", json!({"command": denied_command}));
1936        let denied_ctx = ToolExecutionContext {
1937            executing_supervisor: None,
1938            session_id: Some("s-forced"),
1939            root_session_id: None,
1940            tool_call_id: &denied_call.id,
1941            event_tx: None,
1942            available_tool_schemas: None,
1943            bypass_permissions: true,
1944            auto_approve_permissions: false,
1945            plan_read_only: false,
1946            can_async_resume: false,
1947            bash_completion_sink: None,
1948            pre_parsed_args: None,
1949        };
1950        let denied = crate::approval::with_approval_proxy(
1951            Some(deny_proxy),
1952            executor.execute_with_context(&denied_call, denied_ctx),
1953        )
1954        .await;
1955
1956        assert!(
1957            matches!(denied, Err(ToolError::Execution(ref message)) if message.contains("denied by host")),
1958            "parent denial must block forced-ask execution under bypass: {denied:?}"
1959        );
1960        assert_eq!(denied_requests.load(Ordering::SeqCst), 1);
1961        assert!(!denied_path.exists(), "denied command must not execute");
1962
1963        let approved_path = dir.path().join("forced-approved.txt");
1964        let approved_command = format!("eval 'printf approved > {}'", approved_path.display());
1965        let approved_requests = Arc::new(AtomicUsize::new(0));
1966        let approve_proxy: Arc<dyn crate::approval::ApprovalProxy> =
1967            Arc::new(RecordingApprovalProxy {
1968                requests: approved_requests.clone(),
1969                approve: true,
1970            });
1971        let approved_call = make_tool_call("Bash", json!({"command": approved_command}));
1972        let approved_ctx = ToolExecutionContext {
1973            executing_supervisor: None,
1974            session_id: Some("s-forced"),
1975            root_session_id: None,
1976            tool_call_id: &approved_call.id,
1977            event_tx: None,
1978            available_tool_schemas: None,
1979            bypass_permissions: true,
1980            auto_approve_permissions: false,
1981            plan_read_only: false,
1982            can_async_resume: false,
1983            bash_completion_sink: None,
1984            pre_parsed_args: None,
1985        };
1986        let approved = crate::approval::with_approval_proxy(
1987            Some(approve_proxy),
1988            executor.execute_with_context(&approved_call, approved_ctx),
1989        )
1990        .await;
1991
1992        assert!(
1993            approved.is_ok(),
1994            "parent approval must allow forced-ask execution under bypass: {approved:?}"
1995        );
1996        assert_eq!(approved_requests.load(Ordering::SeqCst), 1);
1997        assert_eq!(fs::read_to_string(approved_path).await.unwrap(), "approved");
1998    }
1999
2000    #[tokio::test]
2001    async fn auto_executes_forced_ask_without_proxy_or_human_event() {
2002        let config = Arc::new(crate::permission::PermissionConfig::new());
2003        let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
2004        let executor = BuiltinToolExecutorBuilder::new()
2005            .with_tool(BashTool::new())
2006            .expect("register Bash tool")
2007            .with_permission_checker(checker)
2008            .build();
2009        let dir = tempfile::tempdir().unwrap();
2010        let path = dir.path().join("auto-forced.txt");
2011        let command = format!("eval 'printf auto > {}'", path.display());
2012        let approval_requests = Arc::new(AtomicUsize::new(0));
2013        let proxy: Arc<dyn crate::approval::ApprovalProxy> = Arc::new(RecordingApprovalProxy {
2014            requests: approval_requests.clone(),
2015            approve: false,
2016        });
2017        let (event_tx, mut event_rx) = mpsc::channel(8);
2018        let call = make_tool_call("Bash", json!({"command": command}));
2019        let ctx = ToolExecutionContext {
2020            executing_supervisor: None,
2021            session_id: Some("s-auto"),
2022            root_session_id: None,
2023            tool_call_id: &call.id,
2024            event_tx: Some(&event_tx),
2025            available_tool_schemas: None,
2026            bypass_permissions: false,
2027            auto_approve_permissions: true,
2028            plan_read_only: false,
2029            can_async_resume: false,
2030            bash_completion_sink: None,
2031            pre_parsed_args: None,
2032        };
2033
2034        let result = crate::approval::with_approval_proxy(
2035            Some(proxy),
2036            executor.execute_with_context(&call, ctx),
2037        )
2038        .await;
2039
2040        assert!(result.is_ok(), "Auto should execute directly: {result:?}");
2041        assert_eq!(fs::read_to_string(path).await.unwrap(), "auto");
2042        assert_eq!(approval_requests.load(Ordering::SeqCst), 0);
2043        assert!(
2044            event_rx.try_recv().is_err(),
2045            "Auto must not emit an interactive approval request"
2046        );
2047    }
2048
2049    #[tokio::test]
2050    async fn read_only_child_checker_denies_every_side_effect_under_auto_and_bypass() {
2051        let config = Arc::new(crate::permission::PermissionConfig::new());
2052        config.set_mode(crate::permission::PermissionMode::Auto);
2053        let base: Arc<dyn crate::permission::PermissionChecker> = Arc::new(
2054            crate::permission::ConfigPermissionChecker::new(config.clone()),
2055        );
2056        let checker = Arc::new(crate::permission::ReadOnlyCommandChecker::new(base));
2057        let executor = BuiltinToolExecutorBuilder::new()
2058            .with_tool(BashTool::new())
2059            .expect("register Bash tool")
2060            .with_tool(WriteTool::new())
2061            .expect("register Write tool")
2062            .with_permission_checker(checker)
2063            .build();
2064
2065        // Command-name validation is not an execution boundary: an ambient
2066        // PATH can resolve `pwd`, `cat`, or `git` to workspace-owned code.
2067        // Therefore even nominal inspection commands stop before Bash under
2068        // both zero-prompt modes.
2069        for (mode, bypass_permissions, auto_approve_permissions) in
2070            [("auto", false, true), ("bypass", true, false)]
2071        {
2072            for command in ["pwd", "cat Cargo.toml"] {
2073                let call = make_tool_call("Bash", json!({"command": command}));
2074                let session_id = format!("planner-no-shell-{mode}");
2075                let ctx = ToolExecutionContext {
2076                    executing_supervisor: None,
2077                    session_id: Some(&session_id),
2078                    root_session_id: None,
2079                    tool_call_id: &call.id,
2080                    event_tx: None,
2081                    available_tool_schemas: None,
2082                    bypass_permissions,
2083                    auto_approve_permissions,
2084                    plan_read_only: false,
2085                    can_async_resume: false,
2086                    bash_completion_sink: None,
2087                    pre_parsed_args: None,
2088                };
2089                let error = executor
2090                    .execute_with_context(&call, ctx)
2091                    .await
2092                    .expect_err("read-only children must not enter an ambient shell");
2093                assert!(error
2094                    .to_string()
2095                    .contains("Execute shell commands is disabled"));
2096            }
2097        }
2098
2099        let dir = tempfile::tempdir().unwrap();
2100        let direct_write_path = dir.path().join("planner-direct-write.txt");
2101        for (mode, bypass_permissions, auto_approve_permissions) in
2102            [("auto", false, true), ("bypass", true, false)]
2103        {
2104            let call = make_tool_call(
2105                "Write",
2106                json!({"file_path": direct_write_path, "content": "blocked"}),
2107            );
2108            let session_id = format!("planner-direct-write-{mode}");
2109            let ctx = ToolExecutionContext {
2110                executing_supervisor: None,
2111                session_id: Some(&session_id),
2112                root_session_id: None,
2113                tool_call_id: &call.id,
2114                event_tx: None,
2115                available_tool_schemas: None,
2116                bypass_permissions,
2117                auto_approve_permissions,
2118                plan_read_only: false,
2119                can_async_resume: false,
2120                bash_completion_sink: None,
2121                pre_parsed_args: None,
2122            };
2123            let error = executor
2124                .execute_with_context(&call, ctx)
2125                .await
2126                .expect_err("unadvertised direct writes must remain hard-denied");
2127            assert!(error
2128                .to_string()
2129                .contains("Write files to disk is disabled"));
2130            assert!(!direct_write_path.exists());
2131        }
2132
2133        let path = dir.path().join("planner-mutation.txt");
2134        let command = format!("printf blocked > {}", path.display());
2135        for (session_id, bypass_permissions, auto_approve_permissions) in [
2136            ("planner-auto", false, true),
2137            ("planner-bypass", true, false),
2138        ] {
2139            let call = make_tool_call("Bash", json!({"command": command.clone()}));
2140            let ctx = ToolExecutionContext {
2141                executing_supervisor: None,
2142                session_id: Some(session_id),
2143                root_session_id: None,
2144                tool_call_id: &call.id,
2145                event_tx: None,
2146                available_tool_schemas: None,
2147                bypass_permissions,
2148                auto_approve_permissions,
2149                plan_read_only: false,
2150                can_async_resume: false,
2151                bash_completion_sink: None,
2152                pre_parsed_args: None,
2153            };
2154
2155            let error = executor
2156                .execute_with_context(&call, ctx)
2157                .await
2158                .expect_err("Auto/Bypass must retain read-only child authority");
2159
2160            assert!(error.to_string().contains("Read-only child"));
2161            assert!(!path.exists());
2162        }
2163
2164        let delete_target = dir.path().join("planner-delete-target");
2165        fs::create_dir_all(&delete_target).await.unwrap();
2166        fs::write(delete_target.join("keep.txt"), "keep")
2167            .await
2168            .unwrap();
2169        let delete_command = format!("rm -rf {}", delete_target.display());
2170        for (session_id, bypass_permissions, auto_approve_permissions) in [
2171            ("planner-delete-auto", false, true),
2172            ("planner-delete-bypass", true, false),
2173        ] {
2174            let call = make_tool_call("Bash", json!({"command": delete_command.clone()}));
2175            let ctx = ToolExecutionContext {
2176                executing_supervisor: None,
2177                session_id: Some(session_id),
2178                root_session_id: None,
2179                tool_call_id: &call.id,
2180                event_tx: None,
2181                available_tool_schemas: None,
2182                bypass_permissions,
2183                auto_approve_permissions,
2184                plan_read_only: false,
2185                can_async_resume: false,
2186                bash_completion_sink: None,
2187                pre_parsed_args: None,
2188            };
2189            let error = executor
2190                .execute_with_context(&call, ctx)
2191                .await
2192                .expect_err("delete operations must remain hard-denied");
2193            assert!(error
2194                .to_string()
2195                .contains("Delete files or directories is disabled"));
2196            assert!(delete_target.exists());
2197        }
2198
2199        let git_output = dir.path().join("planner-git-output.txt");
2200        let git_command = format!("git diff --output={}", git_output.display());
2201        for (session_id, bypass_permissions, auto_approve_permissions) in [
2202            ("planner-git-auto", false, true),
2203            ("planner-git-bypass", true, false),
2204        ] {
2205            let call = make_tool_call("Bash", json!({"command": git_command.clone()}));
2206            let ctx = ToolExecutionContext {
2207                executing_supervisor: None,
2208                session_id: Some(session_id),
2209                root_session_id: None,
2210                tool_call_id: &call.id,
2211                event_tx: None,
2212                available_tool_schemas: None,
2213                bypass_permissions,
2214                auto_approve_permissions,
2215                plan_read_only: false,
2216                can_async_resume: false,
2217                bash_completion_sink: None,
2218                pre_parsed_args: None,
2219            };
2220
2221            let error = executor
2222                .execute_with_context(&call, ctx)
2223                .await
2224                .expect_err("git output flags must not bypass read-only child authority");
2225
2226            assert!(error.to_string().contains("Read-only child"));
2227            assert!(!git_output.exists());
2228        }
2229
2230        let find_output = dir.path().join("planner-find-output.txt");
2231        let denied_commands = [
2232            ("cargo", "cargo test --help".to_string(), None),
2233            (
2234                "git-signature-flag",
2235                "git log --no-ext-diff --no-textconv --show-signature -1".to_string(),
2236                None,
2237            ),
2238            (
2239                "git-signature-format",
2240                "git log --no-ext-diff --no-textconv --no-show-signature --format=%G? -1"
2241                    .to_string(),
2242                None,
2243            ),
2244            (
2245                "find",
2246                format!(
2247                    "find {} -fprint0 {}",
2248                    dir.path().display(),
2249                    find_output.display()
2250                ),
2251                Some(find_output.as_path()),
2252            ),
2253        ];
2254        for (command_kind, command, output_path) in denied_commands {
2255            for (mode, bypass_permissions, auto_approve_permissions) in
2256                [("auto", false, true), ("bypass", true, false)]
2257            {
2258                let call = make_tool_call("Bash", json!({"command": command.clone()}));
2259                let session_id = format!("planner-{command_kind}-{mode}");
2260                let ctx = ToolExecutionContext {
2261                    executing_supervisor: None,
2262                    session_id: Some(&session_id),
2263                    root_session_id: None,
2264                    tool_call_id: &call.id,
2265                    event_tx: None,
2266                    available_tool_schemas: None,
2267                    bypass_permissions,
2268                    auto_approve_permissions,
2269                    plan_read_only: false,
2270                    can_async_resume: false,
2271                    bash_completion_sink: None,
2272                    pre_parsed_args: None,
2273                };
2274
2275                let error = executor
2276                    .execute_with_context(&call, ctx)
2277                    .await
2278                    .expect_err("executable/write-capable commands must remain denied");
2279
2280                assert!(error.to_string().contains("Read-only child"));
2281                if let Some(path) = output_path {
2282                    assert!(!path.exists());
2283                }
2284            }
2285        }
2286
2287        // Bash expands ANSI-C strings before argv reaches `find`; without the
2288        // lexical expansion gate this becomes `find <target> -delete` and
2289        // mutates the workspace even though the raw token is not `-delete`.
2290        let ansi_find_target = dir.path().join("planner-ansi-find-target");
2291        fs::create_dir_all(&ansi_find_target).await.unwrap();
2292        fs::write(ansi_find_target.join("keep.txt"), "keep")
2293            .await
2294            .unwrap();
2295        let ansi_find_command = format!(r"find {} $'-de'lete", ansi_find_target.display());
2296        for (mode, bypass_permissions, auto_approve_permissions) in
2297            [("auto", false, true), ("bypass", true, false)]
2298        {
2299            let call = make_tool_call("Bash", json!({"command": ansi_find_command.clone()}));
2300            let session_id = format!("planner-find-ansi-{mode}");
2301            let ctx = ToolExecutionContext {
2302                executing_supervisor: None,
2303                session_id: Some(&session_id),
2304                root_session_id: None,
2305                tool_call_id: &call.id,
2306                event_tx: None,
2307                available_tool_schemas: None,
2308                bypass_permissions,
2309                auto_approve_permissions,
2310                plan_read_only: false,
2311                can_async_resume: false,
2312                bash_completion_sink: None,
2313                pre_parsed_args: None,
2314            };
2315
2316            let error = executor
2317                .execute_with_context(&call, ctx)
2318                .await
2319                .expect_err("ANSI-C expansion must remain denied before Bash execution");
2320
2321            assert!(error.to_string().contains("Read-only child"));
2322            assert!(
2323                ansi_find_target.exists(),
2324                "the rejected command must not delete its target"
2325            );
2326        }
2327    }
2328
2329    #[tokio::test]
2330    async fn test_explicit_deny_overrides_bypass() {
2331        let dir = tempfile::tempdir().unwrap();
2332        let path = dir.path().join("explicit-deny.txt");
2333        let path_str = path.to_str().unwrap();
2334        let config = Arc::new(crate::permission::PermissionConfig::new());
2335        config.add_rule(crate::permission::PermissionRule::new(
2336            crate::permission::PermissionType::WriteFile,
2337            path_str,
2338            false,
2339        ));
2340        let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
2341        let executor = make_executor(Some(checker));
2342        let call = make_tool_call(
2343            "Write",
2344            json!({"file_path": path_str, "content": "blocked"}),
2345        );
2346        let ctx = ToolExecutionContext {
2347            executing_supervisor: None,
2348            session_id: Some("s-explicit-deny"),
2349            root_session_id: None,
2350            tool_call_id: &call.id,
2351            event_tx: None,
2352            available_tool_schemas: None,
2353            bypass_permissions: true,
2354            auto_approve_permissions: false,
2355            plan_read_only: false,
2356            can_async_resume: false,
2357            bash_completion_sink: None,
2358            pre_parsed_args: None,
2359        };
2360
2361        let result = executor.execute_with_context(&call, ctx).await;
2362        assert!(
2363            matches!(result, Err(ToolError::Execution(ref message)) if message.contains("explicit policy")),
2364            "explicit deny must beat bypass: {result:?}"
2365        );
2366        assert!(!path.exists());
2367    }
2368
2369    #[tokio::test]
2370    async fn test_explicit_delete_deny_overrides_bypass() {
2371        let config = Arc::new(crate::permission::PermissionConfig::new());
2372        config.add_rule(crate::permission::PermissionRule::new(
2373            crate::permission::PermissionType::DeleteOperation,
2374            "rm child-to-preserve",
2375            false,
2376        ));
2377        let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
2378        let executor = BuiltinToolExecutorBuilder::new()
2379            .with_tool(BashTool::new())
2380            .expect("register Bash tool")
2381            .with_permission_checker(checker)
2382            .build();
2383        let call = make_tool_call("Bash", json!({"command": "rm child-to-preserve"}));
2384        let ctx = ToolExecutionContext {
2385            executing_supervisor: None,
2386            session_id: Some("s-explicit-delete-deny"),
2387            root_session_id: None,
2388            tool_call_id: &call.id,
2389            event_tx: None,
2390            available_tool_schemas: None,
2391            bypass_permissions: true,
2392            auto_approve_permissions: false,
2393            plan_read_only: false,
2394            can_async_resume: false,
2395            bash_completion_sink: None,
2396            pre_parsed_args: None,
2397        };
2398
2399        let result = executor.execute_with_context(&call, ctx).await;
2400        assert!(
2401            matches!(result, Err(ToolError::Execution(ref message)) if message.contains("explicit policy")),
2402            "explicit delete deny must beat bypass: {result:?}"
2403        );
2404    }
2405
2406    #[tokio::test]
2407    async fn plan_auto_denies_mutation_but_allows_read_without_a_checker() {
2408        let executor = BuiltinToolExecutor::new();
2409        let dir = tempfile::tempdir().unwrap();
2410        let path = dir.path().join("plan-auto.txt");
2411        let write = make_tool_call(
2412            "Write",
2413            json!({"file_path": path, "content": "must not run"}),
2414        );
2415        let write_ctx = ToolExecutionContext {
2416            executing_supervisor: None,
2417            session_id: Some("plan-auto"),
2418            root_session_id: None,
2419            tool_call_id: &write.id,
2420            event_tx: None,
2421            available_tool_schemas: None,
2422            bypass_permissions: false,
2423            auto_approve_permissions: true,
2424            plan_read_only: true,
2425            can_async_resume: false,
2426            bash_completion_sink: None,
2427            pre_parsed_args: None,
2428        };
2429        let denied = executor.execute_with_context(&write, write_ctx).await;
2430        assert!(matches!(
2431            denied,
2432            Err(ToolError::Execution(ref message)) if message.contains("Plan mode")
2433        ));
2434        assert!(tokio::fs::metadata(&path).await.is_err());
2435
2436        tokio::fs::write(&path, "readable").await.unwrap();
2437        let read = make_tool_call("Read", json!({"file_path": path}));
2438        let read_ctx = ToolExecutionContext {
2439            executing_supervisor: None,
2440            session_id: Some("plan-auto"),
2441            root_session_id: None,
2442            tool_call_id: &read.id,
2443            event_tx: None,
2444            available_tool_schemas: None,
2445            bypass_permissions: false,
2446            auto_approve_permissions: true,
2447            plan_read_only: true,
2448            can_async_resume: false,
2449            bash_completion_sink: None,
2450            pre_parsed_args: None,
2451        };
2452        let allowed = executor
2453            .execute_with_context(&read, read_ctx)
2454            .await
2455            .unwrap();
2456        assert!(allowed.success);
2457    }
2458
2459    #[tokio::test]
2460    async fn auto_request_permissions_fails_without_creating_a_pause() {
2461        let executor = BuiltinToolExecutor::new();
2462        let (event_tx, mut event_rx) = mpsc::channel(4);
2463        let call = make_tool_call("request_permissions", json!({}));
2464        let ctx = ToolExecutionContext {
2465            executing_supervisor: None,
2466            session_id: Some("auto-no-prompt"),
2467            root_session_id: None,
2468            tool_call_id: &call.id,
2469            event_tx: Some(&event_tx),
2470            available_tool_schemas: None,
2471            bypass_permissions: false,
2472            auto_approve_permissions: true,
2473            plan_read_only: false,
2474            can_async_resume: false,
2475            bash_completion_sink: None,
2476            pre_parsed_args: None,
2477        };
2478
2479        let result = executor.execute_with_context_outcome(&call, ctx).await;
2480        assert!(matches!(
2481            result,
2482            Err(ToolError::Execution(ref message)) if message.contains("cannot request expanded permissions")
2483        ));
2484        assert!(event_rx.try_recv().is_err());
2485    }
2486
2487    #[tokio::test]
2488    async fn interactive_gate_returns_synthesized_approval_pause() {
2489        // With an event sink present, a forced-ask rule that yields
2490        // `ConfirmationRequired` must resolve to the synthesized "awaiting
2491        // approval" PAUSE result (a `Completed` result tagged
2492        // `display_preference = "request_permissions"`) — NOT an error — so the
2493        // engine turns it into a clarification pause. This locks in the
2494        // interactive-sink path that the `check_permissions_for` extraction must
2495        // preserve as `Ok(Some(outcome))` rather than collapse to an `Err`.
2496        let config = Arc::new(crate::permission::PermissionConfig::new());
2497        config.set_ask_rules(["Write(/etc/**)".to_string()]);
2498        config.register_session_workspace("s-interactive", "/workspace/project");
2499        let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
2500        let executor = make_executor(Some(checker));
2501
2502        let (tx, mut rx) = mpsc::channel(8);
2503        let call = make_tool_call(
2504            "Write",
2505            json!({"file_path": "/etc/gated.conf", "content": "x"}),
2506        );
2507        let ctx = ToolExecutionContext {
2508            executing_supervisor: None,
2509            session_id: Some("s-interactive"),
2510            root_session_id: None,
2511            tool_call_id: &call.id,
2512            event_tx: Some(&tx),
2513            available_tool_schemas: None,
2514            bypass_permissions: false,
2515            auto_approve_permissions: false,
2516            plan_read_only: false,
2517            can_async_resume: false,
2518            bash_completion_sink: None,
2519            pre_parsed_args: None,
2520        };
2521
2522        let result = executor
2523            .execute_with_context(&call, ctx)
2524            .await
2525            .expect("interactive gate should pause (Ok), not error");
2526
2527        assert_eq!(
2528            result.display_preference.as_deref(),
2529            Some("request_permissions"),
2530            "interactive gate must return the request_permissions pause result"
2531        );
2532        assert!(result.result.contains("awaiting_permission_approval"));
2533        let payload: serde_json::Value = serde_json::from_str(&result.result).expect("payload");
2534        let request = &payload["permission_request"];
2535        assert_eq!(request["request_id"], call.id);
2536        assert_eq!(request["session_id"], "s-interactive");
2537        assert_eq!(request["workspace_path"], "/workspace/project");
2538        assert_eq!(request["reason_code"], "configured_always_ask");
2539        assert_eq!(
2540            request["allowed_decisions"],
2541            json!(["allow_once", "deny_once"])
2542        );
2543        assert_eq!(payload["options"], json!(["Approve", "Deny"]));
2544        assert!(fs::metadata("/etc/gated.conf").await.is_err());
2545
2546        let ev = rx.recv().await.expect("approval event should be emitted");
2547        assert!(
2548            matches!(ev, AgentEvent::ToolApprovalRequested { tool_name, .. } if tool_name == "Write")
2549        );
2550    }
2551
2552    #[tokio::test]
2553    async fn proactive_permission_batch_uses_typed_remembered_scopes_then_completes() {
2554        let config = Arc::new(crate::permission::PermissionConfig::new());
2555        config.set_session_workspace("proactive-session", Some("/workspace/project".to_string()));
2556        let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(
2557            config.clone(),
2558        ));
2559        let executor = BuiltinToolExecutorBuilder::new()
2560            .with_tool(crate::tools::RequestPermissionsTool::new())
2561            .expect("register request_permissions")
2562            .with_permission_checker(checker)
2563            .build();
2564        let call = make_tool_call(
2565            "request_permissions",
2566            json!({
2567                "reason": "Deploy the service",
2568                "permissions": [
2569                    {
2570                        "type": "execute_command",
2571                        "resource": "docker compose up -d"
2572                    },
2573                    {
2574                        "type": "http_request",
2575                        "resource": "registry.example.com"
2576                    }
2577                ]
2578            }),
2579        );
2580        let (event_tx, _event_rx) = mpsc::channel(8);
2581
2582        let first = executor
2583            .execute_with_context(
2584                &call,
2585                ToolExecutionContext {
2586                    executing_supervisor: None,
2587                    session_id: Some("proactive-session"),
2588                    root_session_id: None,
2589                    tool_call_id: &call.id,
2590                    event_tx: Some(&event_tx),
2591                    available_tool_schemas: None,
2592                    bypass_permissions: false,
2593                    auto_approve_permissions: false,
2594                    plan_read_only: false,
2595                    can_async_resume: false,
2596                    bash_completion_sink: None,
2597                    pre_parsed_args: None,
2598                },
2599            )
2600            .await
2601            .expect("first batch context pauses");
2602        let first_payload: serde_json::Value = serde_json::from_str(&first.result).unwrap();
2603        let first_request = &first_payload["permission_request"];
2604        assert_eq!(first_request["resource"], "docker compose up -d");
2605        assert!(!first_request["allowed_decisions"]
2606            .as_array()
2607            .unwrap()
2608            .contains(&json!("allow_once")));
2609        assert!(first_request["allowed_decisions"]
2610            .as_array()
2611            .unwrap()
2612            .contains(&json!("allow_session")));
2613        let first_matcher: crate::permission::PermissionMatcher =
2614            serde_json::from_value(first_request["suggested_matchers"][0].clone()).unwrap();
2615        config
2616            .grant_typed_scoped_session_permission(
2617                "proactive-session",
2618                crate::permission::PermissionType::ExecuteCommand,
2619                first_matcher,
2620            )
2621            .unwrap();
2622
2623        let second = executor
2624            .execute_with_context(
2625                &call,
2626                ToolExecutionContext {
2627                    executing_supervisor: None,
2628                    session_id: Some("proactive-session"),
2629                    root_session_id: None,
2630                    tool_call_id: &call.id,
2631                    event_tx: Some(&event_tx),
2632                    available_tool_schemas: None,
2633                    bypass_permissions: false,
2634                    auto_approve_permissions: false,
2635                    plan_read_only: false,
2636                    can_async_resume: false,
2637                    bash_completion_sink: None,
2638                    pre_parsed_args: None,
2639                },
2640            )
2641            .await
2642            .expect("second batch context pauses");
2643        let second_payload: serde_json::Value = serde_json::from_str(&second.result).unwrap();
2644        let second_request = &second_payload["permission_request"];
2645        assert_eq!(second_request["resource"], "registry.example.com");
2646        let second_matcher: crate::permission::PermissionMatcher =
2647            serde_json::from_value(second_request["suggested_matchers"][0].clone()).unwrap();
2648        config
2649            .grant_typed_scoped_session_permission(
2650                "proactive-session",
2651                crate::permission::PermissionType::HttpRequest,
2652                second_matcher,
2653            )
2654            .unwrap();
2655
2656        let completed = executor
2657            .execute_with_context(
2658                &call,
2659                ToolExecutionContext {
2660                    executing_supervisor: None,
2661                    session_id: Some("proactive-session"),
2662                    root_session_id: None,
2663                    tool_call_id: &call.id,
2664                    event_tx: Some(&event_tx),
2665                    available_tool_schemas: None,
2666                    bypass_permissions: false,
2667                    auto_approve_permissions: false,
2668                    plan_read_only: false,
2669                    can_async_resume: false,
2670                    bash_completion_sink: None,
2671                    pre_parsed_args: None,
2672                },
2673            )
2674            .await
2675            .expect("all authorized contexts complete the tool");
2676        assert!(completed.display_preference.is_none());
2677        let completed_payload: serde_json::Value = serde_json::from_str(&completed.result).unwrap();
2678        assert_eq!(completed_payload["status"], "permissions_authorized");
2679        assert_eq!(
2680            completed_payload["permissions"].as_array().unwrap().len(),
2681            2
2682        );
2683    }
2684
2685    #[tokio::test]
2686    async fn workspace_permission_scope_uses_only_registered_session_identity() {
2687        let registered = Arc::new(crate::permission::PermissionConfig::new());
2688        registered.register_session_workspace("registered", "/workspace/authoritative");
2689        let registered_executor = make_executor(Some(Arc::new(
2690            crate::permission::ConfigPermissionChecker::new(registered.clone()),
2691        )));
2692
2693        let first = permission_request_payload(
2694            &registered_executor,
2695            "registered",
2696            json!({
2697                "file_path": "/tmp/first.txt",
2698                "content": "x",
2699                "cwd": "/model/chosen-a",
2700                "workspace_path": "/model/chosen-b"
2701            }),
2702        )
2703        .await;
2704        let second = permission_request_payload(
2705            &registered_executor,
2706            "registered",
2707            json!({
2708                "file_path": "/tmp/second.txt",
2709                "content": "x",
2710                "cwd": "/model/chosen-c"
2711            }),
2712        )
2713        .await;
2714        for payload in [&first, &second] {
2715            let request = &payload["permission_request"];
2716            assert_eq!(request["workspace_path"], "/workspace/authoritative");
2717            assert!(request["allowed_decisions"]
2718                .as_array()
2719                .unwrap()
2720                .contains(&json!("allow_workspace")));
2721        }
2722
2723        registered.set_session_workspace("registered", None);
2724        let unbound = permission_request_payload(
2725            &registered_executor,
2726            "registered",
2727            json!({
2728                "file_path": "/tmp/unbound.txt",
2729                "content": "x",
2730                "cwd": "/workspace/authoritative"
2731            }),
2732        )
2733        .await;
2734        assert!(unbound["permission_request"]["workspace_path"].is_null());
2735        assert!(!unbound["permission_request"]["allowed_decisions"]
2736            .as_array()
2737            .unwrap()
2738            .contains(&json!("allow_workspace")));
2739
2740        registered.set_session_workspace("registered", Some("/workspace/rebound".to_string()));
2741        let rebound = permission_request_payload(
2742            &registered_executor,
2743            "registered",
2744            json!({
2745                "file_path": "/tmp/rebound.txt",
2746                "content": "x",
2747                "workspace_path": "/workspace/authoritative"
2748            }),
2749        )
2750        .await;
2751        assert_eq!(
2752            rebound["permission_request"]["workspace_path"],
2753            "/workspace/rebound"
2754        );
2755
2756        let unregistered = Arc::new(crate::permission::PermissionConfig::new());
2757        let unregistered_executor = make_executor(Some(Arc::new(
2758            crate::permission::ConfigPermissionChecker::new(unregistered),
2759        )));
2760        let payload = permission_request_payload(
2761            &unregistered_executor,
2762            "unregistered",
2763            json!({
2764                "file_path": "/tmp/unregistered.txt",
2765                "content": "x",
2766                "cwd": "/model/chosen",
2767                "workspace_path": "/also/model/chosen"
2768            }),
2769        )
2770        .await;
2771        let request = &payload["permission_request"];
2772        assert!(request["workspace_path"].is_null());
2773        assert!(!request["allowed_decisions"]
2774            .as_array()
2775            .unwrap()
2776            .contains(&json!("allow_workspace")));
2777    }
2778
2779    #[tokio::test]
2780    async fn check_permissions_for_returns_none_when_permitted() {
2781        // A tool with no matching gate (Read, no checker rule) passes the gate:
2782        // `check_permissions_for` returns `Ok(None)` so the caller runs the tool.
2783        let executor = make_executor(None);
2784        let call = make_tool_call("Read", json!({"file_path": "/tmp/whatever"}));
2785        let ctx = ToolExecutionContext::none(&call.id);
2786        let decision = executor
2787            .check_permissions_for(&call, &ctx)
2788            .await
2789            .expect("no checker means no gate");
2790        assert!(decision.is_none(), "no checker must yield Ok(None)");
2791    }
2792
2793    // ---- Phase 2: cross-process approval proxy ----------------------------
2794
2795    struct HostStub {
2796        approve: bool,
2797    }
2798
2799    #[async_trait]
2800    impl crate::approval::ApprovalProxy for HostStub {
2801        async fn request_approval(&self, _ask: crate::approval::ApprovalAsk) -> bool {
2802            self.approve
2803        }
2804    }
2805
2806    #[tokio::test]
2807    async fn approval_proxy_grant_lets_gated_tool_proceed() {
2808        // A subagent worker installs an ApprovalProxy for its run. A forced-ask
2809        // rule with NO event sink would otherwise fail closed; with the host
2810        // proxy granting, the executor treats the context as approved and the
2811        // tool proceeds inline (no suspend, no synthetic pause).
2812        let dir = tempfile::tempdir().unwrap();
2813        let path = dir.path().join("approved.txt");
2814        let path_str = path.to_str().unwrap().to_string();
2815        let config = Arc::new(crate::permission::PermissionConfig::new());
2816        config.set_ask_rules([format!("Write({}/**)", dir.path().to_str().unwrap())]);
2817        let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
2818        let executor = make_executor(Some(checker));
2819
2820        let call = make_tool_call("Write", json!({"file_path": path_str, "content": "ok"}));
2821        let ctx = ToolExecutionContext {
2822            executing_supervisor: None,
2823            session_id: Some("s-worker"),
2824            root_session_id: None,
2825            tool_call_id: &call.id,
2826            event_tx: None,
2827            available_tool_schemas: None,
2828            bypass_permissions: false,
2829            auto_approve_permissions: false,
2830            plan_read_only: false,
2831            can_async_resume: false,
2832            bash_completion_sink: None,
2833            pre_parsed_args: None,
2834        };
2835
2836        let proxy: Arc<dyn crate::approval::ApprovalProxy> = Arc::new(HostStub { approve: true });
2837        let result = crate::approval::with_approval_proxy(
2838            Some(proxy),
2839            executor.execute_with_context(&call, ctx),
2840        )
2841        .await;
2842
2843        assert!(
2844            result.is_ok(),
2845            "host grant should let the write through: {result:?}"
2846        );
2847        assert_eq!(fs::read_to_string(&path).await.unwrap(), "ok");
2848    }
2849
2850    #[tokio::test]
2851    async fn approval_proxy_deny_fails_gated_tool_closed() {
2852        // With the host proxy denying, the gated tool fails closed and the side
2853        // effect never happens.
2854        let dir = tempfile::tempdir().unwrap();
2855        let path = dir.path().join("denied.txt");
2856        let path_str = path.to_str().unwrap().to_string();
2857        let config = Arc::new(crate::permission::PermissionConfig::new());
2858        config.set_ask_rules([format!("Write({}/**)", dir.path().to_str().unwrap())]);
2859        let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
2860        let executor = make_executor(Some(checker));
2861
2862        let call = make_tool_call("Write", json!({"file_path": path_str, "content": "nope"}));
2863        let ctx = ToolExecutionContext {
2864            executing_supervisor: None,
2865            session_id: Some("s-worker"),
2866            root_session_id: None,
2867            tool_call_id: &call.id,
2868            event_tx: None,
2869            available_tool_schemas: None,
2870            bypass_permissions: false,
2871            auto_approve_permissions: false,
2872            plan_read_only: false,
2873            can_async_resume: false,
2874            bash_completion_sink: None,
2875            pre_parsed_args: None,
2876        };
2877
2878        let proxy: Arc<dyn crate::approval::ApprovalProxy> = Arc::new(HostStub { approve: false });
2879        let result = crate::approval::with_approval_proxy(
2880            Some(proxy),
2881            executor.execute_with_context(&call, ctx),
2882        )
2883        .await;
2884
2885        assert!(
2886            matches!(result, Err(ToolError::Execution(ref m)) if m.contains("denied by host")),
2887            "host deny should fail the tool closed: {result:?}"
2888        );
2889        assert!(fs::metadata(&path).await.is_err());
2890    }
2891
2892    #[tokio::test]
2893    async fn tool_can_stream_events_via_execute_with_context() {
2894        struct StreamingTool;
2895
2896        #[async_trait]
2897        impl Tool for StreamingTool {
2898            fn name(&self) -> &str {
2899                "streaming_tool"
2900            }
2901
2902            fn description(&self) -> &str {
2903                "streams one token"
2904            }
2905
2906            fn parameters_schema(&self) -> serde_json::Value {
2907                json!({"type":"object","properties":{}})
2908            }
2909
2910            async fn invoke(
2911                &self,
2912                _args: serde_json::Value,
2913                ctx: ToolCtx,
2914            ) -> Result<ToolOutcome, ToolError> {
2915                ctx.emit(AgentEvent::Token {
2916                    content: "stream".to_string(),
2917                })
2918                .await;
2919                Ok(ToolOutcome::Completed(ToolResult {
2920                    success: true,
2921                    result: "ok".to_string(),
2922                    display_preference: None,
2923                    images: Vec::new(),
2924                }))
2925            }
2926        }
2927
2928        let executor = BuiltinToolExecutor::new();
2929        executor
2930            .register_tool(StreamingTool)
2931            .expect("register streaming tool");
2932
2933        let (tx, mut rx) = mpsc::channel(8);
2934        let call = make_tool_call("streaming_tool", json!({}));
2935
2936        let result = executor
2937            .execute_with_context(
2938                &call,
2939                ToolExecutionContext {
2940                    executing_supervisor: None,
2941                    session_id: Some("s1"),
2942                    root_session_id: None,
2943                    tool_call_id: &call.id,
2944                    event_tx: Some(&tx),
2945                    available_tool_schemas: None,
2946                    bypass_permissions: false,
2947                    auto_approve_permissions: false,
2948                    plan_read_only: false,
2949                    can_async_resume: false,
2950                    bash_completion_sink: None,
2951                    pre_parsed_args: None,
2952                },
2953            )
2954            .await
2955            .expect("execute tool");
2956
2957        assert!(result.success);
2958        assert_eq!(result.result, "ok");
2959
2960        let ev = rx.recv().await.expect("expected streamed event");
2961        assert!(
2962            matches!(ev, AgentEvent::ToolToken { tool_call_id, content } if tool_call_id == "call_1" && content == "stream")
2963        );
2964    }
2965
2966    #[tokio::test]
2967    async fn removed_legacy_tools_return_not_found() {
2968        let executor = BuiltinToolExecutor::new();
2969
2970        for legacy in ["claude_code", "search_in_file", "search_in_project"] {
2971            let call = make_tool_call(legacy, json!({}));
2972            let result = executor.execute(&call).await;
2973            assert!(matches!(result, Err(ToolError::NotFound(_))));
2974        }
2975    }
2976
2977    #[tokio::test]
2978    async fn executor_prefers_exact_tool_name_before_builtin_alias() {
2979        struct CustomSpawnSessionTool;
2980
2981        #[async_trait]
2982        impl Tool for CustomSpawnSessionTool {
2983            fn name(&self) -> &str {
2984                "spawn_session"
2985            }
2986
2987            fn description(&self) -> &str {
2988                "custom tool for regression coverage"
2989            }
2990
2991            fn parameters_schema(&self) -> serde_json::Value {
2992                json!({"type":"object","properties":{}})
2993            }
2994
2995            async fn invoke(
2996                &self,
2997                _args: serde_json::Value,
2998                _ctx: ToolCtx,
2999            ) -> Result<ToolOutcome, ToolError> {
3000                Ok(ToolOutcome::Completed(ToolResult {
3001                    success: true,
3002                    result: "custom-spawn-session".to_string(),
3003                    display_preference: None,
3004                    images: Vec::new(),
3005                }))
3006            }
3007        }
3008
3009        let executor = BuiltinToolExecutorBuilder::new()
3010            .with_tool(CustomSpawnSessionTool)
3011            .expect("register custom spawn_session tool")
3012            .build();
3013
3014        let call = make_tool_call("spawn_session", json!({}));
3015        let result = executor.execute(&call).await.expect("execute custom tool");
3016        assert!(result.success);
3017        assert_eq!(result.result, "custom-spawn-session");
3018    }
3019
3020    struct ExactRoutingTool {
3021        name: &'static str,
3022        label: &'static str,
3023        args_sensitive: bool,
3024    }
3025
3026    #[async_trait]
3027    impl Tool for ExactRoutingTool {
3028        fn name(&self) -> &str {
3029            self.name
3030        }
3031
3032        fn description(&self) -> &str {
3033            "exact routing regression tool"
3034        }
3035
3036        fn parameters_schema(&self) -> serde_json::Value {
3037            json!({"type":"object","properties":{}})
3038        }
3039
3040        fn classify(&self, args: &serde_json::Value) -> bamboo_agent_core::ToolClass {
3041            let has_builtin_normalized_arg = ["file_path", "command", "pattern"]
3042                .iter()
3043                .any(|key| args.get(key).is_some());
3044            if self.args_sensitive && !has_builtin_normalized_arg {
3045                bamboo_agent_core::ToolClass::READONLY_PARALLEL
3046            } else {
3047                bamboo_agent_core::ToolClass::MUTATING_SERIAL
3048            }
3049        }
3050
3051        async fn invoke(
3052            &self,
3053            args: serde_json::Value,
3054            _ctx: ToolCtx,
3055        ) -> Result<ToolOutcome, ToolError> {
3056            Ok(ToolOutcome::Completed(ToolResult {
3057                success: true,
3058                result: json!({"label": self.label, "args": args}).to_string(),
3059                display_preference: None,
3060                images: Vec::new(),
3061            }))
3062        }
3063    }
3064
3065    #[tokio::test]
3066    async fn executor_preserves_namespaced_exact_identity_and_unqualified_collision() {
3067        let executor = BuiltinToolExecutorBuilder::new()
3068            .with_tool(ExactRoutingTool {
3069                name: "a::custom_tool",
3070                label: "namespaced",
3071                args_sensitive: false,
3072            })
3073            .expect("register namespaced tool")
3074            .with_tool(ExactRoutingTool {
3075                name: "custom_tool",
3076                label: "unqualified",
3077                args_sensitive: false,
3078            })
3079            .expect("register unqualified tool")
3080            .build();
3081
3082        assert!(executor.owns_exact_tool("a::custom_tool"));
3083        assert!(executor.owns_exact_tool("custom_tool"));
3084        assert!(!executor.owns_exact_tool("A::custom_tool"));
3085        let names: Vec<String> = executor
3086            .list_tools()
3087            .into_iter()
3088            .map(|schema| schema.function.name)
3089            .collect();
3090        assert!(names.contains(&"a::custom_tool".to_string()));
3091        assert!(names.contains(&"custom_tool".to_string()));
3092
3093        let namespaced = executor
3094            .execute(&make_tool_call("a::custom_tool", json!({})))
3095            .await
3096            .expect("execute namespaced exact tool");
3097        let unqualified = executor
3098            .execute(&make_tool_call("custom_tool", json!({})))
3099            .await
3100            .expect("execute unqualified exact tool");
3101        assert_eq!(
3102            serde_json::from_str::<serde_json::Value>(&namespaced.result).unwrap()["label"],
3103            "namespaced"
3104        );
3105        assert_eq!(
3106            serde_json::from_str::<serde_json::Value>(&unqualified.result).unwrap()["label"],
3107            "unqualified"
3108        );
3109    }
3110
3111    #[tokio::test]
3112    async fn exact_canonical_shadows_do_not_inherit_builtin_argument_provenance() {
3113        let executor = BuiltinToolExecutorBuilder::new()
3114            .with_tool(ExactRoutingTool {
3115                name: "Read",
3116                label: "exact-read",
3117                args_sensitive: true,
3118            })
3119            .expect("register exact Read shadow")
3120            .with_tool(ExactRoutingTool {
3121                name: "Write",
3122                label: "exact-write",
3123                args_sensitive: true,
3124            })
3125            .expect("register exact Write shadow")
3126            .with_tool(ExactRoutingTool {
3127                name: "Edit",
3128                label: "exact-edit",
3129                args_sensitive: true,
3130            })
3131            .expect("register exact Edit shadow")
3132            .with_tool(ExactRoutingTool {
3133                name: "Bash",
3134                label: "exact-bash",
3135                args_sensitive: true,
3136            })
3137            .expect("register exact Bash shadow")
3138            .with_tool(ExactRoutingTool {
3139                name: "Glob",
3140                label: "exact-glob",
3141                args_sensitive: true,
3142            })
3143            .expect("register exact Glob shadow")
3144            .with_default_tools()
3145            .build();
3146
3147        let cases = [
3148            ("Read", json!({"path": "/tmp/custom-read"}), "file_path"),
3149            ("Write", json!({"path": "/tmp/custom-write"}), "file_path"),
3150            ("Edit", json!({"path": "/tmp/custom-edit"}), "file_path"),
3151            ("Bash", json!({"cmd": "custom-command"}), "command"),
3152            (
3153                "Glob",
3154                json!({"path": "/tmp/custom-glob", "recursive": true}),
3155                "pattern",
3156            ),
3157        ];
3158
3159        for (name, args, normalized_key) in cases {
3160            let call = make_tool_call(name, args.clone());
3161            assert_eq!(
3162                executor.call_mutability(&call),
3163                crate::ToolMutability::ReadOnly,
3164                "custom {name} classification must see the original args"
3165            );
3166            assert!(
3167                executor.call_concurrency_safe(&call),
3168                "custom {name} classification must remain parallel-safe"
3169            );
3170
3171            let result = executor
3172                .execute(&call)
3173                .await
3174                .unwrap_or_else(|error| panic!("execute custom {name}: {error}"));
3175            let result: serde_json::Value = serde_json::from_str(&result.result).unwrap();
3176            assert_eq!(result["args"], args, "custom {name} args changed");
3177            assert!(result["args"].get(normalized_key).is_none());
3178        }
3179
3180        // Exercise the permission entry point with exact canonical shadows for
3181        // which the central policy has no name-based write/execute rule. The
3182        // same raw args must reach classification and invocation even when a
3183        // checker is installed.
3184        let permission_executor = BuiltinToolExecutorBuilder::new()
3185            .with_tool(ExactRoutingTool {
3186                name: "Read",
3187                label: "permission-read",
3188                args_sensitive: true,
3189            })
3190            .expect("register permission-aware Read shadow")
3191            .with_tool(ExactRoutingTool {
3192                name: "Glob",
3193                label: "permission-glob",
3194                args_sensitive: true,
3195            })
3196            .expect("register permission-aware Glob shadow")
3197            .with_default_tools()
3198            .with_permission_checker(Arc::new(crate::permission::AllowAllPermissionChecker))
3199            .build();
3200        for (name, args) in [
3201            ("Read", json!({"path": "/tmp/permission-read"})),
3202            (
3203                "Glob",
3204                json!({"path": "/tmp/permission-glob", "recursive": true}),
3205            ),
3206        ] {
3207            let call = make_tool_call(name, args.clone());
3208            let ctx = ToolExecutionContext::none(&call.id);
3209            assert!(permission_executor
3210                .check_permissions_for(&call, &ctx)
3211                .await
3212                .expect("permission check")
3213                .is_none());
3214            assert_eq!(
3215                permission_executor.call_mutability(&call),
3216                crate::ToolMutability::ReadOnly
3217            );
3218            assert!(permission_executor.call_concurrency_safe(&call));
3219            let result = permission_executor.execute(&call).await.unwrap();
3220            let result: serde_json::Value = serde_json::from_str(&result.result).unwrap();
3221            assert_eq!(result["args"], args);
3222        }
3223    }
3224
3225    #[tokio::test]
3226    async fn exact_apply_patch_keeps_original_args_and_classification() {
3227        let executor = BuiltinToolExecutorBuilder::new()
3228            .with_filesystem_tool("Edit")
3229            .expect("register builtin Edit")
3230            .with_tool(ExactRoutingTool {
3231                name: "apply_patch",
3232                label: "exact-apply-patch",
3233                args_sensitive: true,
3234            })
3235            .expect("register exact apply_patch shadow")
3236            .build();
3237        let call = make_tool_call("apply_patch", json!({"path": "/tmp/exact-shadow"}));
3238
3239        let (mutability, parallel_safe) = executor.call_parallel_classification(&call);
3240        assert_eq!(mutability, crate::ToolMutability::ReadOnly);
3241        assert!(parallel_safe);
3242
3243        let result = executor.execute(&call).await.expect("execute exact shadow");
3244        let result: serde_json::Value = serde_json::from_str(&result.result).unwrap();
3245        assert_eq!(result["label"], "exact-apply-patch");
3246        assert_eq!(result["args"]["path"], "/tmp/exact-shadow");
3247        assert!(result["args"].get("file_path").is_none());
3248    }
3249
3250    #[tokio::test]
3251    async fn exact_permission_seam_preserves_default_apply_patch_builtin_provenance() {
3252        let executor = BuiltinToolExecutorBuilder::new()
3253            .with_filesystem_tool("Edit")
3254            .expect("register builtin Edit")
3255            .with_permission_checker(Arc::new(crate::permission::AllowAllPermissionChecker))
3256            .build();
3257        let raw_args = json!({
3258            "path": "/tmp/exact-permission-apply-patch.txt",
3259            "old_string": "before",
3260            "new_string": "after"
3261        });
3262        let call = make_tool_call("default::apply_patch", raw_args.clone());
3263        let ctx = ToolExecutionContext {
3264            pre_parsed_args: Some(&raw_args),
3265            ..ToolExecutionContext::none(&call.id)
3266        };
3267
3268        assert!(executor
3269            .check_permissions_for_exact(&call, "Edit", &ctx)
3270            .await
3271            .expect("normalized builtin permission check")
3272            .is_none());
3273        assert_eq!(call.function.name, "default::apply_patch");
3274        assert_eq!(
3275            serde_json::from_str::<serde_json::Value>(&call.function.arguments).unwrap(),
3276            raw_args
3277        );
3278    }
3279
3280    #[tokio::test]
3281    async fn unshadowed_alias_and_namespace_keep_legacy_argument_compatibility() {
3282        let dir = tempfile::tempdir().unwrap();
3283        let path = dir.path().join("legacy-alias.txt");
3284        fs::write(&path, "before").await.unwrap();
3285        let executor = BuiltinToolExecutorBuilder::new()
3286            .with_filesystem_tool("Edit")
3287            .expect("register builtin Edit")
3288            .with_permission_checker(Arc::new(crate::permission::AllowAllPermissionChecker))
3289            .build();
3290
3291        let result = executor
3292            .execute(&make_tool_call(
3293                "default::apply_patch",
3294                json!({
3295                    "path": path,
3296                    "old_string": "before",
3297                    "new_string": "after"
3298                }),
3299            ))
3300            .await
3301            .expect("execute unshadowed alias");
3302        assert!(result.success);
3303        assert_eq!(fs::read_to_string(path).await.unwrap(), "after");
3304    }
3305
3306    // ---- issue #106: parse tool args once on the execute path -------------
3307
3308    /// A tool that echoes back the `v` field of the args it was invoked with, so
3309    /// a test can observe *which* parsed value reached the tool.
3310    struct EchoArgsTool;
3311
3312    #[async_trait]
3313    impl Tool for EchoArgsTool {
3314        fn name(&self) -> &str {
3315            "echo_args"
3316        }
3317        fn description(&self) -> &str {
3318            "echoes the `v` arg"
3319        }
3320        fn parameters_schema(&self) -> serde_json::Value {
3321            json!({"type":"object","properties":{"v":{"type":"string"}}})
3322        }
3323        async fn invoke(
3324            &self,
3325            args: serde_json::Value,
3326            _ctx: ToolCtx,
3327        ) -> Result<ToolOutcome, ToolError> {
3328            let v = args
3329                .get("v")
3330                .and_then(serde_json::Value::as_str)
3331                .unwrap_or("<none>")
3332                .to_string();
3333            Ok(ToolOutcome::Completed(ToolResult {
3334                success: true,
3335                result: v,
3336                display_preference: None,
3337                images: Vec::new(),
3338            }))
3339        }
3340    }
3341
3342    fn ctx_with_pre_parsed<'a>(
3343        call_id: &'a str,
3344        pre_parsed: Option<&'a serde_json::Value>,
3345    ) -> ToolExecutionContext<'a> {
3346        ToolExecutionContext {
3347            executing_supervisor: None,
3348            session_id: Some("s-106"),
3349            root_session_id: None,
3350            tool_call_id: call_id,
3351            event_tx: None,
3352            available_tool_schemas: None,
3353            bypass_permissions: false,
3354            auto_approve_permissions: false,
3355            plan_read_only: false,
3356            can_async_resume: false,
3357            bash_completion_sink: None,
3358            pre_parsed_args: pre_parsed,
3359        }
3360    }
3361
3362    #[tokio::test]
3363    async fn execute_with_context_reuses_pre_parsed_args_without_reparsing() {
3364        // The raw `arguments` string and the threaded `pre_parsed_args` Value
3365        // deliberately disagree. If the executor honored the contract (parse
3366        // once at the dispatch site, reuse downstream), the tool sees the
3367        // pre-parsed value; if it re-parsed the raw string it would see "raw".
3368        // This is the load-bearing proof that the second parse was eliminated.
3369        let executor = BuiltinToolExecutor::new();
3370        executor.register_tool(EchoArgsTool).expect("register echo");
3371
3372        let call = make_tool_call("echo_args", json!({"v": "raw"}));
3373        let pre_parsed = json!({"v": "preparsed"});
3374        let ctx = ctx_with_pre_parsed(&call.id, Some(&pre_parsed));
3375
3376        let result = executor
3377            .execute_with_context(&call, ctx)
3378            .await
3379            .expect("execute echo tool");
3380        assert_eq!(
3381            result.result, "preparsed",
3382            "executor must reuse pre_parsed_args, not re-parse the raw string"
3383        );
3384    }
3385
3386    #[tokio::test]
3387    async fn execute_with_context_parses_raw_when_no_pre_parsed_args() {
3388        // Without a threaded value (the `execute` entry point / tests / a loop
3389        // that parsed with a different parser), the executor falls back to
3390        // parsing the raw string exactly as before — behavior preserved.
3391        let executor = BuiltinToolExecutor::new();
3392        executor.register_tool(EchoArgsTool).expect("register echo");
3393
3394        let call = make_tool_call("echo_args", json!({"v": "raw"}));
3395        let ctx = ctx_with_pre_parsed(&call.id, None);
3396
3397        let result = executor
3398            .execute_with_context(&call, ctx)
3399            .await
3400            .expect("execute echo tool");
3401        assert_eq!(
3402            result.result, "raw",
3403            "without pre_parsed_args the executor parses the raw string as before"
3404        );
3405    }
3406
3407    #[tokio::test]
3408    async fn execute_with_context_malformed_args_repair_unchanged_without_pre_parsed() {
3409        // Malformed (truncated) JSON must still be auto-repaired by the
3410        // fallback parse when no pre-parsed value is threaded — the existing
3411        // error/leniency behavior is untouched by the dedup.
3412        let dir = tempfile::tempdir().unwrap();
3413        let path = dir.path().join("recovered-no-preparsed.txt");
3414        let malformed_args = format!(
3415            r#"{{"file_path":"{}","content":"recovered content""#,
3416            path.display()
3417        );
3418
3419        let executor = BuiltinToolExecutor::new();
3420        let call = make_tool_call_with_raw_args("Write", &malformed_args);
3421        let ctx = ctx_with_pre_parsed(&call.id, None);
3422
3423        let result = executor
3424            .execute_with_context(&call, ctx)
3425            .await
3426            .expect("truncated JSON should be auto-repaired");
3427        assert!(result.success);
3428        let written = fs::read_to_string(&path).await.expect("file written");
3429        assert_eq!(written, "recovered content");
3430    }
3431
3432    #[tokio::test]
3433    async fn successful_write_emits_one_bounded_file_changed_event() {
3434        let dir = tempfile::tempdir().unwrap();
3435        let path = dir.path().join("write-event.txt");
3436        let path_string = path.to_string_lossy().into_owned();
3437        let padded_path = format!("  {path_string}  ");
3438        let recorder = Arc::new(InMemoryToolEventRecorder::new(4).unwrap());
3439        let executor = BuiltinToolExecutorBuilder::new()
3440            .with_filesystem_tool("Write")
3441            .unwrap()
3442            .with_tool_event_publisher(recorder.clone())
3443            .build();
3444        let call = make_tool_call_with_id(
3445            "write-call",
3446            "Write",
3447            json!({"file_path": padded_path, "content": "written"}),
3448        );
3449
3450        let result = executor
3451            .execute_with_context(
3452                &call,
3453                tool_event_context(&call, Some("write-session"), Some("write-root-session")),
3454            )
3455            .await
3456            .unwrap();
3457
3458        assert!(result.success);
3459        assert_eq!(fs::read_to_string(path).await.unwrap(), "written");
3460        assert_single_file_changed(
3461            &recorder,
3462            "write-session",
3463            "write-root-session",
3464            "Write",
3465            "write-call",
3466            &path_string,
3467        );
3468    }
3469
3470    #[tokio::test]
3471    async fn successful_edit_emits_one_bounded_file_changed_event() {
3472        let dir = tempfile::tempdir().unwrap();
3473        let path = dir.path().join("edit-event.txt");
3474        fs::write(&path, "before\n").await.unwrap();
3475        let path_string = path.to_string_lossy().into_owned();
3476        let padded_path = format!(" {path_string} ");
3477        let recorder = Arc::new(InMemoryToolEventRecorder::new(4).unwrap());
3478        let executor = BuiltinToolExecutor::new().with_tool_event_publisher(recorder.clone());
3479        let read =
3480            make_tool_call_with_id("edit-read-call", "Read", json!({"file_path": padded_path}));
3481        executor
3482            .execute_with_context(
3483                &read,
3484                tool_event_context(&read, Some("edit-session"), Some("edit-root-session")),
3485            )
3486            .await
3487            .unwrap();
3488        assert!(recorder.try_snapshot().unwrap().is_empty());
3489
3490        let edit = make_tool_call_with_id(
3491            "edit-call",
3492            "Edit",
3493            json!({
3494                "file_path": format!(" {path_string} "),
3495                "old_string": "before",
3496                "new_string": "after"
3497            }),
3498        );
3499        let result = executor
3500            .execute_with_context(
3501                &edit,
3502                tool_event_context(&edit, Some("edit-session"), Some("edit-root-session")),
3503            )
3504            .await
3505            .unwrap();
3506
3507        assert!(result.success);
3508        assert_eq!(fs::read_to_string(path).await.unwrap(), "after\n");
3509        assert_single_file_changed(
3510            &recorder,
3511            "edit-session",
3512            "edit-root-session",
3513            "Edit",
3514            "edit-call",
3515            &path_string,
3516        );
3517    }
3518
3519    #[tokio::test]
3520    async fn successful_apply_patch_alias_emits_canonical_edit_with_original_call_id() {
3521        let dir = tempfile::tempdir().unwrap();
3522        let path = dir.path().join("apply-patch-event.txt");
3523        fs::write(&path, "alpha\nbeta\n").await.unwrap();
3524        let path_string = path.to_string_lossy().into_owned();
3525        let recorder = Arc::new(InMemoryToolEventRecorder::new(4).unwrap());
3526        let executor = BuiltinToolExecutor::new().with_tool_event_publisher(recorder.clone());
3527        let read = make_tool_call_with_id(
3528            "apply-patch-read-call",
3529            "Read",
3530            json!({"file_path": path_string}),
3531        );
3532        executor
3533            .execute_with_context(
3534                &read,
3535                tool_event_context(&read, Some("alias-session"), Some("alias-root-session")),
3536            )
3537            .await
3538            .unwrap();
3539
3540        let edit = make_tool_call_with_id(
3541            "model-original-alias-call",
3542            "apply_patch",
3543            json!({
3544                "path": format!("  {path_string}  "),
3545                "old_string": "beta",
3546                "new_string": "BETA"
3547            }),
3548        );
3549        let result = executor
3550            .execute_with_context(
3551                &edit,
3552                tool_event_context(&edit, Some("alias-session"), Some("alias-root-session")),
3553            )
3554            .await
3555            .unwrap();
3556
3557        assert!(result.success);
3558        assert_eq!(fs::read_to_string(path).await.unwrap(), "alpha\nBETA\n");
3559        assert_single_file_changed(
3560            &recorder,
3561            "alias-session",
3562            "alias-root-session",
3563            "Edit",
3564            "model-original-alias-call",
3565            &path_string,
3566        );
3567    }
3568
3569    #[tokio::test]
3570    async fn successful_notebook_edit_emits_one_bounded_file_changed_event() {
3571        let dir = tempfile::tempdir().unwrap();
3572        let path = dir.path().join("notebook-event.ipynb");
3573        fs::write(
3574            &path,
3575            r#"{"cells":[],"metadata":{},"nbformat":4,"nbformat_minor":5}"#,
3576        )
3577        .await
3578        .unwrap();
3579        let path_string = path.to_string_lossy().into_owned();
3580        let recorder = Arc::new(InMemoryToolEventRecorder::new(4).unwrap());
3581        let executor = BuiltinToolExecutorBuilder::new()
3582            .with_filesystem_tool("NotebookEdit")
3583            .unwrap()
3584            .with_tool_event_publisher(recorder.clone())
3585            .build();
3586        let call = make_tool_call_with_id(
3587            "notebook-call",
3588            "NotebookEdit",
3589            json!({
3590                "notebook_path": format!(" {path_string} "),
3591                "new_source": "print('hello')",
3592                "cell_type": "code",
3593                "edit_mode": "insert"
3594            }),
3595        );
3596
3597        let result = executor
3598            .execute_with_context(
3599                &call,
3600                tool_event_context(
3601                    &call,
3602                    Some("notebook-session"),
3603                    Some("notebook-root-session"),
3604                ),
3605            )
3606            .await
3607            .unwrap();
3608
3609        assert!(result.success);
3610        assert_single_file_changed(
3611            &recorder,
3612            "notebook-session",
3613            "notebook-root-session",
3614            "NotebookEdit",
3615            "notebook-call",
3616            &path_string,
3617        );
3618    }
3619
3620    #[cfg(unix)]
3621    #[tokio::test]
3622    async fn write_through_intermediate_symlink_fails_and_emits_zero_events() {
3623        use std::os::unix::fs::symlink;
3624
3625        let workspace = tempfile::tempdir().unwrap();
3626        let external = tempfile::tempdir().unwrap();
3627        let linked_dir = workspace.path().join("linked");
3628        symlink(external.path(), &linked_dir).unwrap();
3629        let target = linked_dir.join("write.txt");
3630        let recorder = Arc::new(InMemoryToolEventRecorder::new(4).unwrap());
3631        let executor = BuiltinToolExecutorBuilder::new()
3632            .with_filesystem_tool("Write")
3633            .unwrap()
3634            .with_tool_event_publisher(recorder.clone())
3635            .build();
3636        let call = make_tool_call_with_id(
3637            "symlink-write",
3638            "Write",
3639            json!({"file_path": target, "content": "must-not-write"}),
3640        );
3641
3642        let result = executor
3643            .execute_with_context(
3644                &call,
3645                tool_event_context(&call, Some("symlink-session"), Some("symlink-root")),
3646            )
3647            .await;
3648        assert!(
3649            result.is_err() || result.as_ref().is_ok_and(|result| !result.success),
3650            "Write must fail closed through an intermediate symlink"
3651        );
3652        assert!(!external.path().join("write.txt").exists());
3653        assert!(recorder.try_snapshot().unwrap().is_empty());
3654    }
3655
3656    #[cfg(unix)]
3657    #[tokio::test]
3658    async fn edit_of_symlinked_file_fails_and_emits_zero_events() {
3659        use std::os::unix::fs::symlink;
3660
3661        let dir = tempfile::tempdir().unwrap();
3662        let real = dir.path().join("real.txt");
3663        let linked = dir.path().join("linked.txt");
3664        fs::write(&real, "before\n").await.unwrap();
3665        symlink(&real, &linked).unwrap();
3666        let recorder = Arc::new(InMemoryToolEventRecorder::new(4).unwrap());
3667        let executor = BuiltinToolExecutor::new().with_tool_event_publisher(recorder.clone());
3668        let read =
3669            make_tool_call_with_id("symlink-edit-read", "Read", json!({"file_path": linked}));
3670        let _ = executor
3671            .execute_with_context(
3672                &read,
3673                tool_event_context(&read, Some("symlink-session"), Some("symlink-root")),
3674            )
3675            .await;
3676        let edit = make_tool_call_with_id(
3677            "symlink-edit",
3678            "Edit",
3679            json!({
3680                "file_path": linked,
3681                "old_string": "before",
3682                "new_string": "after"
3683            }),
3684        );
3685
3686        let result = executor
3687            .execute_with_context(
3688                &edit,
3689                tool_event_context(&edit, Some("symlink-session"), Some("symlink-root")),
3690            )
3691            .await;
3692        assert!(
3693            result.is_err() || result.as_ref().is_ok_and(|result| !result.success),
3694            "Edit must fail closed for a symlinked final file"
3695        );
3696        assert_eq!(fs::read_to_string(&real).await.unwrap(), "before\n");
3697        assert!(recorder.try_snapshot().unwrap().is_empty());
3698    }
3699
3700    #[cfg(unix)]
3701    #[tokio::test]
3702    async fn notebook_edit_through_intermediate_symlink_fails_and_emits_zero_events() {
3703        use std::os::unix::fs::symlink;
3704
3705        let workspace = tempfile::tempdir().unwrap();
3706        let external = tempfile::tempdir().unwrap();
3707        let real_notebook = external.path().join("real.ipynb");
3708        let original = r#"{"cells":[],"metadata":{},"nbformat":4,"nbformat_minor":5}"#;
3709        fs::write(&real_notebook, original).await.unwrap();
3710        let linked_dir = workspace.path().join("linked");
3711        symlink(external.path(), &linked_dir).unwrap();
3712        let recorder = Arc::new(InMemoryToolEventRecorder::new(4).unwrap());
3713        let executor = BuiltinToolExecutorBuilder::new()
3714            .with_filesystem_tool("NotebookEdit")
3715            .unwrap()
3716            .with_tool_event_publisher(recorder.clone())
3717            .build();
3718        let call = make_tool_call_with_id(
3719            "symlink-notebook",
3720            "NotebookEdit",
3721            json!({
3722                "notebook_path": linked_dir.join("real.ipynb"),
3723                "new_source": "print('must not write')",
3724                "cell_type": "code",
3725                "edit_mode": "insert"
3726            }),
3727        );
3728
3729        let result = executor
3730            .execute_with_context(
3731                &call,
3732                tool_event_context(&call, Some("symlink-session"), Some("symlink-root")),
3733            )
3734            .await;
3735        assert!(
3736            result.is_err() || result.as_ref().is_ok_and(|result| !result.success),
3737            "NotebookEdit must fail closed through an intermediate symlink"
3738        );
3739        assert_eq!(fs::read_to_string(&real_notebook).await.unwrap(), original);
3740        assert!(recorder.try_snapshot().unwrap().is_empty());
3741    }
3742
3743    #[tokio::test]
3744    async fn failed_and_non_successful_mutations_emit_no_event() {
3745        let recorder = Arc::new(InMemoryToolEventRecorder::new(4).unwrap());
3746        let executor = BuiltinToolExecutorBuilder::new()
3747            .with_filesystem_tool("Write")
3748            .unwrap()
3749            .with_tool_event_publisher(recorder.clone())
3750            .build();
3751        let failed = make_tool_call_with_id(
3752            "failed-write-call",
3753            "Write",
3754            json!({"file_path": "relative.txt", "content": "never"}),
3755        );
3756        assert!(executor
3757            .execute_with_context(
3758                &failed,
3759                tool_event_context(
3760                    &failed,
3761                    Some("failure-session"),
3762                    Some("failure-root-session"),
3763                ),
3764            )
3765            .await
3766            .is_err());
3767        assert!(recorder.try_snapshot().unwrap().is_empty());
3768
3769        let completed_false = marked_stub_write_executor(false, recorder.clone());
3770        let call = make_tool_call_with_id(
3771            "completed-false-call",
3772            "Write",
3773            json!({"file_path": "/valid/event/path.txt"}),
3774        );
3775        let result = completed_false
3776            .execute_with_context(
3777                &call,
3778                tool_event_context(&call, Some("failure-session"), Some("failure-root-session")),
3779            )
3780            .await
3781            .unwrap();
3782        assert!(!result.success);
3783        assert!(recorder.try_snapshot().unwrap().is_empty());
3784    }
3785
3786    #[tokio::test]
3787    async fn committed_postverify_failure_emits_no_tool_event() {
3788        let dir = tempfile::tempdir().unwrap();
3789        let path = dir.path().join("postverify-conflict.txt");
3790        fs::write(&path, "before").await.unwrap();
3791        let path_string = path.to_string_lossy().into_owned();
3792        let session_id = format!("event-conflict-{}", uuid::Uuid::new_v4());
3793        let recorder = Arc::new(InMemoryToolEventRecorder::new(4).unwrap());
3794        let executor =
3795            Arc::new(BuiltinToolExecutor::new().with_tool_event_publisher(recorder.clone()));
3796
3797        let initial_read = make_tool_call_with_id(
3798            "conflict-initial-read",
3799            "Read",
3800            json!({"file_path": path_string}),
3801        );
3802        executor
3803            .execute_with_context(
3804                &initial_read,
3805                tool_event_context(
3806                    &initial_read,
3807                    Some(&session_id),
3808                    Some("conflict-root-session"),
3809                ),
3810            )
3811            .await
3812            .unwrap();
3813        let (advance_reached, resume_advance) =
3814            crate::tools::read_tracker::pause_next_advance_for_test(&session_id, &path_string)
3815                .await;
3816
3817        let writer_executor = executor.clone();
3818        let writer_session = session_id.clone();
3819        let writer_path = path_string.clone();
3820        let writer = tokio::spawn(async move {
3821            let call = make_tool_call_with_id(
3822                "conflict-write-call",
3823                "Write",
3824                json!({"file_path": writer_path, "content": "intended"}),
3825            );
3826            writer_executor
3827                .execute_with_context(
3828                    &call,
3829                    tool_event_context(&call, Some(&writer_session), Some("conflict-root-session")),
3830                )
3831                .await
3832        });
3833
3834        tokio::time::timeout(
3835            std::time::Duration::from_secs(5),
3836            advance_reached.notified(),
3837        )
3838        .await
3839        .expect("Write did not reach post-write baseline advancement");
3840        fs::write(&path, "other").await.unwrap();
3841        let concurrent_read = make_tool_call_with_id(
3842            "conflict-concurrent-read",
3843            "Read",
3844            json!({"file_path": path_string}),
3845        );
3846        executor
3847            .execute_with_context(
3848                &concurrent_read,
3849                tool_event_context(
3850                    &concurrent_read,
3851                    Some(&session_id),
3852                    Some("conflict-root-session"),
3853                ),
3854            )
3855            .await
3856            .unwrap();
3857        fs::write(&path, "intended").await.unwrap();
3858        resume_advance.notify_one();
3859
3860        let outcome = tokio::time::timeout(std::time::Duration::from_secs(5), writer)
3861            .await
3862            .expect("Write did not resume")
3863            .unwrap();
3864        assert!(
3865            matches!(outcome, Err(ToolError::Execution(ref message)) if message.contains("Write committed")),
3866            "committed postverify conflict must stay an error: {outcome:?}"
3867        );
3868        assert_eq!(fs::read_to_string(path).await.unwrap(), "intended");
3869        assert!(
3870            recorder.try_snapshot().unwrap().is_empty(),
3871            "an on-disk mutation is not a successful tool outcome"
3872        );
3873    }
3874
3875    #[tokio::test]
3876    async fn permission_pause_does_not_publish_a_success_event() {
3877        let dir = tempfile::tempdir().unwrap();
3878        let path = dir.path().join("approval-gated.txt");
3879        let config = Arc::new(crate::permission::PermissionConfig::new());
3880        config.set_ask_rules([format!("Write({}/**)", dir.path().display())]);
3881        config.register_session_workspace(
3882            "approval-session",
3883            dir.path().to_string_lossy().into_owned(),
3884        );
3885        let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
3886        let recorder = Arc::new(InMemoryToolEventRecorder::new(4).unwrap());
3887        let executor = BuiltinToolExecutorBuilder::new()
3888            .with_filesystem_tool("Write")
3889            .unwrap()
3890            .with_permission_checker(checker)
3891            .with_tool_event_publisher(recorder.clone())
3892            .build();
3893        let call = make_tool_call_with_id(
3894            "approval-call",
3895            "Write",
3896            json!({"file_path": path, "content": "not-yet"}),
3897        );
3898        let (event_tx, _event_rx) = mpsc::channel(4);
3899        let mut ctx = tool_event_context(
3900            &call,
3901            Some("approval-session"),
3902            Some("approval-root-session"),
3903        );
3904        ctx.event_tx = Some(&event_tx);
3905
3906        let result = executor.execute_with_context(&call, ctx).await.unwrap();
3907        assert!(
3908            result.success,
3909            "approval pause is a synthetic success result"
3910        );
3911        assert_eq!(
3912            result.display_preference.as_deref(),
3913            Some("request_permissions")
3914        );
3915        assert!(!path.exists(), "permission pause must not invoke Write");
3916        assert!(recorder.try_snapshot().unwrap().is_empty());
3917    }
3918
3919    #[tokio::test]
3920    async fn missing_authority_or_oversize_path_fails_closed_without_event() {
3921        let recorder = Arc::new(InMemoryToolEventRecorder::new(4).unwrap());
3922        let executor = marked_stub_write_executor(true, recorder.clone());
3923
3924        let missing_session = make_tool_call_with_id(
3925            "missing-session-call",
3926            "Write",
3927            json!({"file_path": "/bounded/path.txt"}),
3928        );
3929        assert!(
3930            executor
3931                .execute_with_context(
3932                    &missing_session,
3933                    tool_event_context(&missing_session, None, Some("authority-root-session"),),
3934                )
3935                .await
3936                .unwrap()
3937                .success
3938        );
3939
3940        let missing_root = make_tool_call_with_id(
3941            "missing-root-call",
3942            "Write",
3943            json!({"file_path": "/bounded/path.txt"}),
3944        );
3945        assert!(
3946            executor
3947                .execute_with_context(
3948                    &missing_root,
3949                    tool_event_context(&missing_root, Some("authority-session"), None),
3950                )
3951                .await
3952                .unwrap()
3953                .success
3954        );
3955
3956        let oversize_path = make_tool_call_with_id(
3957            "oversize-path-call",
3958            "Write",
3959            json!({"file_path": "x".repeat(MAX_TOOL_EVENT_PATH_BYTES + 1)}),
3960        );
3961        assert!(
3962            executor
3963                .execute_with_context(
3964                    &oversize_path,
3965                    tool_event_context(
3966                        &oversize_path,
3967                        Some("authority-session"),
3968                        Some("authority-root-session"),
3969                    ),
3970                )
3971                .await
3972                .unwrap()
3973                .success
3974        );
3975
3976        assert!(recorder.try_snapshot().unwrap().is_empty());
3977    }
3978
3979    #[tokio::test]
3980    async fn custom_write_name_never_acquires_builtin_event_provenance() {
3981        let recorder = Arc::new(InMemoryToolEventRecorder::new(4).unwrap());
3982        let registry = ToolRegistry::new();
3983        registry.register(StubWriteTool { success: true }).unwrap();
3984        let from_registry = BuiltinToolExecutor::with_registry(registry)
3985            .with_tool_event_publisher(recorder.clone());
3986        let first = make_tool_call_with_id(
3987            "spoof-registry-call",
3988            "Write",
3989            json!({"file_path": "/spoof/path.txt"}),
3990        );
3991        assert!(
3992            from_registry
3993                .execute_with_context(
3994                    &first,
3995                    tool_event_context(&first, Some("spoof-session"), Some("spoof-root-session"),),
3996                )
3997                .await
3998                .unwrap()
3999                .success
4000        );
4001
4002        let custom_before_defaults = BuiltinToolExecutorBuilder::new()
4003            .with_tool(StubWriteTool { success: true })
4004            .unwrap()
4005            .with_default_tools()
4006            .with_tool_event_publisher(recorder.clone())
4007            .build();
4008        let second = make_tool_call_with_id(
4009            "spoof-builder-order-call",
4010            "Write",
4011            json!({"file_path": "/spoof/path.txt"}),
4012        );
4013        assert!(
4014            custom_before_defaults
4015                .execute_with_context(
4016                    &second,
4017                    tool_event_context(&second, Some("spoof-session"), Some("spoof-root-session"),),
4018                )
4019                .await
4020                .unwrap()
4021                .success
4022        );
4023
4024        let replaced_builtin =
4025            BuiltinToolExecutor::new().with_tool_event_publisher(recorder.clone());
4026        assert!(replaced_builtin.registry().unregister("Write"));
4027        replaced_builtin
4028            .register_tool(StubWriteTool { success: true })
4029            .unwrap();
4030        let third = make_tool_call_with_id(
4031            "spoof-replaced-builtin-call",
4032            "Write",
4033            json!({"file_path": "/spoof/path.txt"}),
4034        );
4035        assert!(
4036            replaced_builtin
4037                .execute_with_context(
4038                    &third,
4039                    tool_event_context(&third, Some("spoof-session"), Some("spoof-root-session"),),
4040                )
4041                .await
4042                .unwrap()
4043                .success
4044        );
4045
4046        assert!(recorder.try_snapshot().unwrap().is_empty());
4047    }
4048
4049    #[tokio::test]
4050    async fn publisher_rejection_or_panic_never_changes_successful_tool_result() {
4051        let full = Arc::new(InMemoryToolEventRecorder::new(1).unwrap());
4052        full.try_publish(seed_event("seed-full")).unwrap();
4053        assert_real_write_succeeds_with_publisher(full.clone(), "full").await;
4054        let retained = full.try_snapshot().unwrap();
4055        assert_eq!(retained.len(), 1);
4056        assert_eq!(retained[0].context.tool_call_id, "seed-full");
4057
4058        let publishers: Vec<(&str, Arc<dyn ToolEventPublisher>)> = vec![
4059            (
4060                "busy",
4061                Arc::new(ReturningPublisher(ToolEventPublishError::Busy)),
4062            ),
4063            (
4064                "poisoned",
4065                Arc::new(ReturningPublisher(ToolEventPublishError::Poisoned)),
4066            ),
4067            (
4068                "failed",
4069                Arc::new(ReturningPublisher(ToolEventPublishError::Failed(
4070                    "sink unavailable".to_string(),
4071                ))),
4072            ),
4073            ("enabled-panic", Arc::new(IsEnabledPanicPublisher)),
4074            ("publish-panic", Arc::new(TryPublishPanicPublisher)),
4075        ];
4076        for (label, publisher) in publishers {
4077            assert_real_write_succeeds_with_publisher(publisher, label).await;
4078        }
4079    }
4080}