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 auto_never_overrides_guardian_read_only_hard_deny() {
2051        let config = Arc::new(crate::permission::PermissionConfig::new());
2052        let base: Arc<dyn crate::permission::PermissionChecker> = Arc::new(
2053            crate::permission::ConfigPermissionChecker::new(config.clone()),
2054        );
2055        let checker = Arc::new(crate::permission::GuardianReadOnlyChecker::new(base));
2056        let executor = BuiltinToolExecutorBuilder::new()
2057            .with_tool(BashTool::new())
2058            .expect("register Bash tool")
2059            .with_permission_checker(checker)
2060            .build();
2061        let dir = tempfile::tempdir().unwrap();
2062        let path = dir.path().join("guardian-mutation.txt");
2063        let command = format!("printf blocked > {}", path.display());
2064        let call = make_tool_call("Bash", json!({"command": command}));
2065        let ctx = ToolExecutionContext {
2066            executing_supervisor: None,
2067            session_id: Some("guardian-auto"),
2068            root_session_id: None,
2069            tool_call_id: &call.id,
2070            event_tx: None,
2071            available_tool_schemas: None,
2072            bypass_permissions: false,
2073            auto_approve_permissions: true,
2074            plan_read_only: false,
2075            can_async_resume: false,
2076            bash_completion_sink: None,
2077            pre_parsed_args: None,
2078        };
2079
2080        let error = executor
2081            .execute_with_context(&call, ctx)
2082            .await
2083            .expect_err("Auto must retain Guardian read-only authority");
2084
2085        assert!(error.to_string().contains("Guardian reviewer is read-only"));
2086        assert!(!path.exists());
2087    }
2088
2089    #[tokio::test]
2090    async fn test_explicit_deny_overrides_bypass() {
2091        let dir = tempfile::tempdir().unwrap();
2092        let path = dir.path().join("explicit-deny.txt");
2093        let path_str = path.to_str().unwrap();
2094        let config = Arc::new(crate::permission::PermissionConfig::new());
2095        config.add_rule(crate::permission::PermissionRule::new(
2096            crate::permission::PermissionType::WriteFile,
2097            path_str,
2098            false,
2099        ));
2100        let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
2101        let executor = make_executor(Some(checker));
2102        let call = make_tool_call(
2103            "Write",
2104            json!({"file_path": path_str, "content": "blocked"}),
2105        );
2106        let ctx = ToolExecutionContext {
2107            executing_supervisor: None,
2108            session_id: Some("s-explicit-deny"),
2109            root_session_id: None,
2110            tool_call_id: &call.id,
2111            event_tx: None,
2112            available_tool_schemas: None,
2113            bypass_permissions: true,
2114            auto_approve_permissions: false,
2115            plan_read_only: false,
2116            can_async_resume: false,
2117            bash_completion_sink: None,
2118            pre_parsed_args: None,
2119        };
2120
2121        let result = executor.execute_with_context(&call, ctx).await;
2122        assert!(
2123            matches!(result, Err(ToolError::Execution(ref message)) if message.contains("explicit policy")),
2124            "explicit deny must beat bypass: {result:?}"
2125        );
2126        assert!(!path.exists());
2127    }
2128
2129    #[tokio::test]
2130    async fn test_explicit_delete_deny_overrides_bypass() {
2131        let config = Arc::new(crate::permission::PermissionConfig::new());
2132        config.add_rule(crate::permission::PermissionRule::new(
2133            crate::permission::PermissionType::DeleteOperation,
2134            "rm child-to-preserve",
2135            false,
2136        ));
2137        let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
2138        let executor = BuiltinToolExecutorBuilder::new()
2139            .with_tool(BashTool::new())
2140            .expect("register Bash tool")
2141            .with_permission_checker(checker)
2142            .build();
2143        let call = make_tool_call("Bash", json!({"command": "rm child-to-preserve"}));
2144        let ctx = ToolExecutionContext {
2145            executing_supervisor: None,
2146            session_id: Some("s-explicit-delete-deny"),
2147            root_session_id: None,
2148            tool_call_id: &call.id,
2149            event_tx: None,
2150            available_tool_schemas: None,
2151            bypass_permissions: true,
2152            auto_approve_permissions: false,
2153            plan_read_only: false,
2154            can_async_resume: false,
2155            bash_completion_sink: None,
2156            pre_parsed_args: None,
2157        };
2158
2159        let result = executor.execute_with_context(&call, ctx).await;
2160        assert!(
2161            matches!(result, Err(ToolError::Execution(ref message)) if message.contains("explicit policy")),
2162            "explicit delete deny must beat bypass: {result:?}"
2163        );
2164    }
2165
2166    #[tokio::test]
2167    async fn plan_auto_denies_mutation_but_allows_read_without_a_checker() {
2168        let executor = BuiltinToolExecutor::new();
2169        let dir = tempfile::tempdir().unwrap();
2170        let path = dir.path().join("plan-auto.txt");
2171        let write = make_tool_call(
2172            "Write",
2173            json!({"file_path": path, "content": "must not run"}),
2174        );
2175        let write_ctx = ToolExecutionContext {
2176            executing_supervisor: None,
2177            session_id: Some("plan-auto"),
2178            root_session_id: None,
2179            tool_call_id: &write.id,
2180            event_tx: None,
2181            available_tool_schemas: None,
2182            bypass_permissions: false,
2183            auto_approve_permissions: true,
2184            plan_read_only: true,
2185            can_async_resume: false,
2186            bash_completion_sink: None,
2187            pre_parsed_args: None,
2188        };
2189        let denied = executor.execute_with_context(&write, write_ctx).await;
2190        assert!(matches!(
2191            denied,
2192            Err(ToolError::Execution(ref message)) if message.contains("Plan mode")
2193        ));
2194        assert!(tokio::fs::metadata(&path).await.is_err());
2195
2196        tokio::fs::write(&path, "readable").await.unwrap();
2197        let read = make_tool_call("Read", json!({"file_path": path}));
2198        let read_ctx = ToolExecutionContext {
2199            executing_supervisor: None,
2200            session_id: Some("plan-auto"),
2201            root_session_id: None,
2202            tool_call_id: &read.id,
2203            event_tx: None,
2204            available_tool_schemas: None,
2205            bypass_permissions: false,
2206            auto_approve_permissions: true,
2207            plan_read_only: true,
2208            can_async_resume: false,
2209            bash_completion_sink: None,
2210            pre_parsed_args: None,
2211        };
2212        let allowed = executor
2213            .execute_with_context(&read, read_ctx)
2214            .await
2215            .unwrap();
2216        assert!(allowed.success);
2217    }
2218
2219    #[tokio::test]
2220    async fn auto_request_permissions_fails_without_creating_a_pause() {
2221        let executor = BuiltinToolExecutor::new();
2222        let (event_tx, mut event_rx) = mpsc::channel(4);
2223        let call = make_tool_call("request_permissions", json!({}));
2224        let ctx = ToolExecutionContext {
2225            executing_supervisor: None,
2226            session_id: Some("auto-no-prompt"),
2227            root_session_id: None,
2228            tool_call_id: &call.id,
2229            event_tx: Some(&event_tx),
2230            available_tool_schemas: None,
2231            bypass_permissions: false,
2232            auto_approve_permissions: true,
2233            plan_read_only: false,
2234            can_async_resume: false,
2235            bash_completion_sink: None,
2236            pre_parsed_args: None,
2237        };
2238
2239        let result = executor.execute_with_context_outcome(&call, ctx).await;
2240        assert!(matches!(
2241            result,
2242            Err(ToolError::Execution(ref message)) if message.contains("cannot request expanded permissions")
2243        ));
2244        assert!(event_rx.try_recv().is_err());
2245    }
2246
2247    #[tokio::test]
2248    async fn interactive_gate_returns_synthesized_approval_pause() {
2249        // With an event sink present, a forced-ask rule that yields
2250        // `ConfirmationRequired` must resolve to the synthesized "awaiting
2251        // approval" PAUSE result (a `Completed` result tagged
2252        // `display_preference = "request_permissions"`) — NOT an error — so the
2253        // engine turns it into a clarification pause. This locks in the
2254        // interactive-sink path that the `check_permissions_for` extraction must
2255        // preserve as `Ok(Some(outcome))` rather than collapse to an `Err`.
2256        let config = Arc::new(crate::permission::PermissionConfig::new());
2257        config.set_ask_rules(["Write(/etc/**)".to_string()]);
2258        config.register_session_workspace("s-interactive", "/workspace/project");
2259        let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
2260        let executor = make_executor(Some(checker));
2261
2262        let (tx, mut rx) = mpsc::channel(8);
2263        let call = make_tool_call(
2264            "Write",
2265            json!({"file_path": "/etc/gated.conf", "content": "x"}),
2266        );
2267        let ctx = ToolExecutionContext {
2268            executing_supervisor: None,
2269            session_id: Some("s-interactive"),
2270            root_session_id: None,
2271            tool_call_id: &call.id,
2272            event_tx: Some(&tx),
2273            available_tool_schemas: None,
2274            bypass_permissions: false,
2275            auto_approve_permissions: false,
2276            plan_read_only: false,
2277            can_async_resume: false,
2278            bash_completion_sink: None,
2279            pre_parsed_args: None,
2280        };
2281
2282        let result = executor
2283            .execute_with_context(&call, ctx)
2284            .await
2285            .expect("interactive gate should pause (Ok), not error");
2286
2287        assert_eq!(
2288            result.display_preference.as_deref(),
2289            Some("request_permissions"),
2290            "interactive gate must return the request_permissions pause result"
2291        );
2292        assert!(result.result.contains("awaiting_permission_approval"));
2293        let payload: serde_json::Value = serde_json::from_str(&result.result).expect("payload");
2294        let request = &payload["permission_request"];
2295        assert_eq!(request["request_id"], call.id);
2296        assert_eq!(request["session_id"], "s-interactive");
2297        assert_eq!(request["workspace_path"], "/workspace/project");
2298        assert_eq!(request["reason_code"], "configured_always_ask");
2299        assert_eq!(
2300            request["allowed_decisions"],
2301            json!(["allow_once", "deny_once"])
2302        );
2303        assert_eq!(payload["options"], json!(["Approve", "Deny"]));
2304        assert!(fs::metadata("/etc/gated.conf").await.is_err());
2305
2306        let ev = rx.recv().await.expect("approval event should be emitted");
2307        assert!(
2308            matches!(ev, AgentEvent::ToolApprovalRequested { tool_name, .. } if tool_name == "Write")
2309        );
2310    }
2311
2312    #[tokio::test]
2313    async fn proactive_permission_batch_uses_typed_remembered_scopes_then_completes() {
2314        let config = Arc::new(crate::permission::PermissionConfig::new());
2315        config.set_session_workspace("proactive-session", Some("/workspace/project".to_string()));
2316        let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(
2317            config.clone(),
2318        ));
2319        let executor = BuiltinToolExecutorBuilder::new()
2320            .with_tool(crate::tools::RequestPermissionsTool::new())
2321            .expect("register request_permissions")
2322            .with_permission_checker(checker)
2323            .build();
2324        let call = make_tool_call(
2325            "request_permissions",
2326            json!({
2327                "reason": "Deploy the service",
2328                "permissions": [
2329                    {
2330                        "type": "execute_command",
2331                        "resource": "docker compose up -d"
2332                    },
2333                    {
2334                        "type": "http_request",
2335                        "resource": "registry.example.com"
2336                    }
2337                ]
2338            }),
2339        );
2340        let (event_tx, _event_rx) = mpsc::channel(8);
2341
2342        let first = executor
2343            .execute_with_context(
2344                &call,
2345                ToolExecutionContext {
2346                    executing_supervisor: None,
2347                    session_id: Some("proactive-session"),
2348                    root_session_id: None,
2349                    tool_call_id: &call.id,
2350                    event_tx: Some(&event_tx),
2351                    available_tool_schemas: None,
2352                    bypass_permissions: false,
2353                    auto_approve_permissions: false,
2354                    plan_read_only: false,
2355                    can_async_resume: false,
2356                    bash_completion_sink: None,
2357                    pre_parsed_args: None,
2358                },
2359            )
2360            .await
2361            .expect("first batch context pauses");
2362        let first_payload: serde_json::Value = serde_json::from_str(&first.result).unwrap();
2363        let first_request = &first_payload["permission_request"];
2364        assert_eq!(first_request["resource"], "docker compose up -d");
2365        assert!(!first_request["allowed_decisions"]
2366            .as_array()
2367            .unwrap()
2368            .contains(&json!("allow_once")));
2369        assert!(first_request["allowed_decisions"]
2370            .as_array()
2371            .unwrap()
2372            .contains(&json!("allow_session")));
2373        let first_matcher: crate::permission::PermissionMatcher =
2374            serde_json::from_value(first_request["suggested_matchers"][0].clone()).unwrap();
2375        config
2376            .grant_typed_scoped_session_permission(
2377                "proactive-session",
2378                crate::permission::PermissionType::ExecuteCommand,
2379                first_matcher,
2380            )
2381            .unwrap();
2382
2383        let second = executor
2384            .execute_with_context(
2385                &call,
2386                ToolExecutionContext {
2387                    executing_supervisor: None,
2388                    session_id: Some("proactive-session"),
2389                    root_session_id: None,
2390                    tool_call_id: &call.id,
2391                    event_tx: Some(&event_tx),
2392                    available_tool_schemas: None,
2393                    bypass_permissions: false,
2394                    auto_approve_permissions: false,
2395                    plan_read_only: false,
2396                    can_async_resume: false,
2397                    bash_completion_sink: None,
2398                    pre_parsed_args: None,
2399                },
2400            )
2401            .await
2402            .expect("second batch context pauses");
2403        let second_payload: serde_json::Value = serde_json::from_str(&second.result).unwrap();
2404        let second_request = &second_payload["permission_request"];
2405        assert_eq!(second_request["resource"], "registry.example.com");
2406        let second_matcher: crate::permission::PermissionMatcher =
2407            serde_json::from_value(second_request["suggested_matchers"][0].clone()).unwrap();
2408        config
2409            .grant_typed_scoped_session_permission(
2410                "proactive-session",
2411                crate::permission::PermissionType::HttpRequest,
2412                second_matcher,
2413            )
2414            .unwrap();
2415
2416        let completed = executor
2417            .execute_with_context(
2418                &call,
2419                ToolExecutionContext {
2420                    executing_supervisor: None,
2421                    session_id: Some("proactive-session"),
2422                    root_session_id: None,
2423                    tool_call_id: &call.id,
2424                    event_tx: Some(&event_tx),
2425                    available_tool_schemas: None,
2426                    bypass_permissions: false,
2427                    auto_approve_permissions: false,
2428                    plan_read_only: false,
2429                    can_async_resume: false,
2430                    bash_completion_sink: None,
2431                    pre_parsed_args: None,
2432                },
2433            )
2434            .await
2435            .expect("all authorized contexts complete the tool");
2436        assert!(completed.display_preference.is_none());
2437        let completed_payload: serde_json::Value = serde_json::from_str(&completed.result).unwrap();
2438        assert_eq!(completed_payload["status"], "permissions_authorized");
2439        assert_eq!(
2440            completed_payload["permissions"].as_array().unwrap().len(),
2441            2
2442        );
2443    }
2444
2445    #[tokio::test]
2446    async fn workspace_permission_scope_uses_only_registered_session_identity() {
2447        let registered = Arc::new(crate::permission::PermissionConfig::new());
2448        registered.register_session_workspace("registered", "/workspace/authoritative");
2449        let registered_executor = make_executor(Some(Arc::new(
2450            crate::permission::ConfigPermissionChecker::new(registered.clone()),
2451        )));
2452
2453        let first = permission_request_payload(
2454            &registered_executor,
2455            "registered",
2456            json!({
2457                "file_path": "/tmp/first.txt",
2458                "content": "x",
2459                "cwd": "/model/chosen-a",
2460                "workspace_path": "/model/chosen-b"
2461            }),
2462        )
2463        .await;
2464        let second = permission_request_payload(
2465            &registered_executor,
2466            "registered",
2467            json!({
2468                "file_path": "/tmp/second.txt",
2469                "content": "x",
2470                "cwd": "/model/chosen-c"
2471            }),
2472        )
2473        .await;
2474        for payload in [&first, &second] {
2475            let request = &payload["permission_request"];
2476            assert_eq!(request["workspace_path"], "/workspace/authoritative");
2477            assert!(request["allowed_decisions"]
2478                .as_array()
2479                .unwrap()
2480                .contains(&json!("allow_workspace")));
2481        }
2482
2483        registered.set_session_workspace("registered", None);
2484        let unbound = permission_request_payload(
2485            &registered_executor,
2486            "registered",
2487            json!({
2488                "file_path": "/tmp/unbound.txt",
2489                "content": "x",
2490                "cwd": "/workspace/authoritative"
2491            }),
2492        )
2493        .await;
2494        assert!(unbound["permission_request"]["workspace_path"].is_null());
2495        assert!(!unbound["permission_request"]["allowed_decisions"]
2496            .as_array()
2497            .unwrap()
2498            .contains(&json!("allow_workspace")));
2499
2500        registered.set_session_workspace("registered", Some("/workspace/rebound".to_string()));
2501        let rebound = permission_request_payload(
2502            &registered_executor,
2503            "registered",
2504            json!({
2505                "file_path": "/tmp/rebound.txt",
2506                "content": "x",
2507                "workspace_path": "/workspace/authoritative"
2508            }),
2509        )
2510        .await;
2511        assert_eq!(
2512            rebound["permission_request"]["workspace_path"],
2513            "/workspace/rebound"
2514        );
2515
2516        let unregistered = Arc::new(crate::permission::PermissionConfig::new());
2517        let unregistered_executor = make_executor(Some(Arc::new(
2518            crate::permission::ConfigPermissionChecker::new(unregistered),
2519        )));
2520        let payload = permission_request_payload(
2521            &unregistered_executor,
2522            "unregistered",
2523            json!({
2524                "file_path": "/tmp/unregistered.txt",
2525                "content": "x",
2526                "cwd": "/model/chosen",
2527                "workspace_path": "/also/model/chosen"
2528            }),
2529        )
2530        .await;
2531        let request = &payload["permission_request"];
2532        assert!(request["workspace_path"].is_null());
2533        assert!(!request["allowed_decisions"]
2534            .as_array()
2535            .unwrap()
2536            .contains(&json!("allow_workspace")));
2537    }
2538
2539    #[tokio::test]
2540    async fn check_permissions_for_returns_none_when_permitted() {
2541        // A tool with no matching gate (Read, no checker rule) passes the gate:
2542        // `check_permissions_for` returns `Ok(None)` so the caller runs the tool.
2543        let executor = make_executor(None);
2544        let call = make_tool_call("Read", json!({"file_path": "/tmp/whatever"}));
2545        let ctx = ToolExecutionContext::none(&call.id);
2546        let decision = executor
2547            .check_permissions_for(&call, &ctx)
2548            .await
2549            .expect("no checker means no gate");
2550        assert!(decision.is_none(), "no checker must yield Ok(None)");
2551    }
2552
2553    // ---- Phase 2: cross-process approval proxy ----------------------------
2554
2555    struct HostStub {
2556        approve: bool,
2557    }
2558
2559    #[async_trait]
2560    impl crate::approval::ApprovalProxy for HostStub {
2561        async fn request_approval(&self, _ask: crate::approval::ApprovalAsk) -> bool {
2562            self.approve
2563        }
2564    }
2565
2566    #[tokio::test]
2567    async fn approval_proxy_grant_lets_gated_tool_proceed() {
2568        // A subagent worker installs an ApprovalProxy for its run. A forced-ask
2569        // rule with NO event sink would otherwise fail closed; with the host
2570        // proxy granting, the executor treats the context as approved and the
2571        // tool proceeds inline (no suspend, no synthetic pause).
2572        let dir = tempfile::tempdir().unwrap();
2573        let path = dir.path().join("approved.txt");
2574        let path_str = path.to_str().unwrap().to_string();
2575        let config = Arc::new(crate::permission::PermissionConfig::new());
2576        config.set_ask_rules([format!("Write({}/**)", dir.path().to_str().unwrap())]);
2577        let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
2578        let executor = make_executor(Some(checker));
2579
2580        let call = make_tool_call("Write", json!({"file_path": path_str, "content": "ok"}));
2581        let ctx = ToolExecutionContext {
2582            executing_supervisor: None,
2583            session_id: Some("s-worker"),
2584            root_session_id: None,
2585            tool_call_id: &call.id,
2586            event_tx: None,
2587            available_tool_schemas: None,
2588            bypass_permissions: false,
2589            auto_approve_permissions: false,
2590            plan_read_only: false,
2591            can_async_resume: false,
2592            bash_completion_sink: None,
2593            pre_parsed_args: None,
2594        };
2595
2596        let proxy: Arc<dyn crate::approval::ApprovalProxy> = Arc::new(HostStub { approve: true });
2597        let result = crate::approval::with_approval_proxy(
2598            Some(proxy),
2599            executor.execute_with_context(&call, ctx),
2600        )
2601        .await;
2602
2603        assert!(
2604            result.is_ok(),
2605            "host grant should let the write through: {result:?}"
2606        );
2607        assert_eq!(fs::read_to_string(&path).await.unwrap(), "ok");
2608    }
2609
2610    #[tokio::test]
2611    async fn approval_proxy_deny_fails_gated_tool_closed() {
2612        // With the host proxy denying, the gated tool fails closed and the side
2613        // effect never happens.
2614        let dir = tempfile::tempdir().unwrap();
2615        let path = dir.path().join("denied.txt");
2616        let path_str = path.to_str().unwrap().to_string();
2617        let config = Arc::new(crate::permission::PermissionConfig::new());
2618        config.set_ask_rules([format!("Write({}/**)", dir.path().to_str().unwrap())]);
2619        let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
2620        let executor = make_executor(Some(checker));
2621
2622        let call = make_tool_call("Write", json!({"file_path": path_str, "content": "nope"}));
2623        let ctx = ToolExecutionContext {
2624            executing_supervisor: None,
2625            session_id: Some("s-worker"),
2626            root_session_id: None,
2627            tool_call_id: &call.id,
2628            event_tx: None,
2629            available_tool_schemas: None,
2630            bypass_permissions: false,
2631            auto_approve_permissions: false,
2632            plan_read_only: false,
2633            can_async_resume: false,
2634            bash_completion_sink: None,
2635            pre_parsed_args: None,
2636        };
2637
2638        let proxy: Arc<dyn crate::approval::ApprovalProxy> = Arc::new(HostStub { approve: false });
2639        let result = crate::approval::with_approval_proxy(
2640            Some(proxy),
2641            executor.execute_with_context(&call, ctx),
2642        )
2643        .await;
2644
2645        assert!(
2646            matches!(result, Err(ToolError::Execution(ref m)) if m.contains("denied by host")),
2647            "host deny should fail the tool closed: {result:?}"
2648        );
2649        assert!(fs::metadata(&path).await.is_err());
2650    }
2651
2652    #[tokio::test]
2653    async fn tool_can_stream_events_via_execute_with_context() {
2654        struct StreamingTool;
2655
2656        #[async_trait]
2657        impl Tool for StreamingTool {
2658            fn name(&self) -> &str {
2659                "streaming_tool"
2660            }
2661
2662            fn description(&self) -> &str {
2663                "streams one token"
2664            }
2665
2666            fn parameters_schema(&self) -> serde_json::Value {
2667                json!({"type":"object","properties":{}})
2668            }
2669
2670            async fn invoke(
2671                &self,
2672                _args: serde_json::Value,
2673                ctx: ToolCtx,
2674            ) -> Result<ToolOutcome, ToolError> {
2675                ctx.emit(AgentEvent::Token {
2676                    content: "stream".to_string(),
2677                })
2678                .await;
2679                Ok(ToolOutcome::Completed(ToolResult {
2680                    success: true,
2681                    result: "ok".to_string(),
2682                    display_preference: None,
2683                    images: Vec::new(),
2684                }))
2685            }
2686        }
2687
2688        let executor = BuiltinToolExecutor::new();
2689        executor
2690            .register_tool(StreamingTool)
2691            .expect("register streaming tool");
2692
2693        let (tx, mut rx) = mpsc::channel(8);
2694        let call = make_tool_call("streaming_tool", json!({}));
2695
2696        let result = executor
2697            .execute_with_context(
2698                &call,
2699                ToolExecutionContext {
2700                    executing_supervisor: None,
2701                    session_id: Some("s1"),
2702                    root_session_id: None,
2703                    tool_call_id: &call.id,
2704                    event_tx: Some(&tx),
2705                    available_tool_schemas: None,
2706                    bypass_permissions: false,
2707                    auto_approve_permissions: false,
2708                    plan_read_only: false,
2709                    can_async_resume: false,
2710                    bash_completion_sink: None,
2711                    pre_parsed_args: None,
2712                },
2713            )
2714            .await
2715            .expect("execute tool");
2716
2717        assert!(result.success);
2718        assert_eq!(result.result, "ok");
2719
2720        let ev = rx.recv().await.expect("expected streamed event");
2721        assert!(
2722            matches!(ev, AgentEvent::ToolToken { tool_call_id, content } if tool_call_id == "call_1" && content == "stream")
2723        );
2724    }
2725
2726    #[tokio::test]
2727    async fn removed_legacy_tools_return_not_found() {
2728        let executor = BuiltinToolExecutor::new();
2729
2730        for legacy in ["claude_code", "search_in_file", "search_in_project"] {
2731            let call = make_tool_call(legacy, json!({}));
2732            let result = executor.execute(&call).await;
2733            assert!(matches!(result, Err(ToolError::NotFound(_))));
2734        }
2735    }
2736
2737    #[tokio::test]
2738    async fn executor_prefers_exact_tool_name_before_builtin_alias() {
2739        struct CustomSpawnSessionTool;
2740
2741        #[async_trait]
2742        impl Tool for CustomSpawnSessionTool {
2743            fn name(&self) -> &str {
2744                "spawn_session"
2745            }
2746
2747            fn description(&self) -> &str {
2748                "custom tool for regression coverage"
2749            }
2750
2751            fn parameters_schema(&self) -> serde_json::Value {
2752                json!({"type":"object","properties":{}})
2753            }
2754
2755            async fn invoke(
2756                &self,
2757                _args: serde_json::Value,
2758                _ctx: ToolCtx,
2759            ) -> Result<ToolOutcome, ToolError> {
2760                Ok(ToolOutcome::Completed(ToolResult {
2761                    success: true,
2762                    result: "custom-spawn-session".to_string(),
2763                    display_preference: None,
2764                    images: Vec::new(),
2765                }))
2766            }
2767        }
2768
2769        let executor = BuiltinToolExecutorBuilder::new()
2770            .with_tool(CustomSpawnSessionTool)
2771            .expect("register custom spawn_session tool")
2772            .build();
2773
2774        let call = make_tool_call("spawn_session", json!({}));
2775        let result = executor.execute(&call).await.expect("execute custom tool");
2776        assert!(result.success);
2777        assert_eq!(result.result, "custom-spawn-session");
2778    }
2779
2780    struct ExactRoutingTool {
2781        name: &'static str,
2782        label: &'static str,
2783        args_sensitive: bool,
2784    }
2785
2786    #[async_trait]
2787    impl Tool for ExactRoutingTool {
2788        fn name(&self) -> &str {
2789            self.name
2790        }
2791
2792        fn description(&self) -> &str {
2793            "exact routing regression tool"
2794        }
2795
2796        fn parameters_schema(&self) -> serde_json::Value {
2797            json!({"type":"object","properties":{}})
2798        }
2799
2800        fn classify(&self, args: &serde_json::Value) -> bamboo_agent_core::ToolClass {
2801            let has_builtin_normalized_arg = ["file_path", "command", "pattern"]
2802                .iter()
2803                .any(|key| args.get(key).is_some());
2804            if self.args_sensitive && !has_builtin_normalized_arg {
2805                bamboo_agent_core::ToolClass::READONLY_PARALLEL
2806            } else {
2807                bamboo_agent_core::ToolClass::MUTATING_SERIAL
2808            }
2809        }
2810
2811        async fn invoke(
2812            &self,
2813            args: serde_json::Value,
2814            _ctx: ToolCtx,
2815        ) -> Result<ToolOutcome, ToolError> {
2816            Ok(ToolOutcome::Completed(ToolResult {
2817                success: true,
2818                result: json!({"label": self.label, "args": args}).to_string(),
2819                display_preference: None,
2820                images: Vec::new(),
2821            }))
2822        }
2823    }
2824
2825    #[tokio::test]
2826    async fn executor_preserves_namespaced_exact_identity_and_unqualified_collision() {
2827        let executor = BuiltinToolExecutorBuilder::new()
2828            .with_tool(ExactRoutingTool {
2829                name: "a::custom_tool",
2830                label: "namespaced",
2831                args_sensitive: false,
2832            })
2833            .expect("register namespaced tool")
2834            .with_tool(ExactRoutingTool {
2835                name: "custom_tool",
2836                label: "unqualified",
2837                args_sensitive: false,
2838            })
2839            .expect("register unqualified tool")
2840            .build();
2841
2842        assert!(executor.owns_exact_tool("a::custom_tool"));
2843        assert!(executor.owns_exact_tool("custom_tool"));
2844        assert!(!executor.owns_exact_tool("A::custom_tool"));
2845        let names: Vec<String> = executor
2846            .list_tools()
2847            .into_iter()
2848            .map(|schema| schema.function.name)
2849            .collect();
2850        assert!(names.contains(&"a::custom_tool".to_string()));
2851        assert!(names.contains(&"custom_tool".to_string()));
2852
2853        let namespaced = executor
2854            .execute(&make_tool_call("a::custom_tool", json!({})))
2855            .await
2856            .expect("execute namespaced exact tool");
2857        let unqualified = executor
2858            .execute(&make_tool_call("custom_tool", json!({})))
2859            .await
2860            .expect("execute unqualified exact tool");
2861        assert_eq!(
2862            serde_json::from_str::<serde_json::Value>(&namespaced.result).unwrap()["label"],
2863            "namespaced"
2864        );
2865        assert_eq!(
2866            serde_json::from_str::<serde_json::Value>(&unqualified.result).unwrap()["label"],
2867            "unqualified"
2868        );
2869    }
2870
2871    #[tokio::test]
2872    async fn exact_canonical_shadows_do_not_inherit_builtin_argument_provenance() {
2873        let executor = BuiltinToolExecutorBuilder::new()
2874            .with_tool(ExactRoutingTool {
2875                name: "Read",
2876                label: "exact-read",
2877                args_sensitive: true,
2878            })
2879            .expect("register exact Read shadow")
2880            .with_tool(ExactRoutingTool {
2881                name: "Write",
2882                label: "exact-write",
2883                args_sensitive: true,
2884            })
2885            .expect("register exact Write shadow")
2886            .with_tool(ExactRoutingTool {
2887                name: "Edit",
2888                label: "exact-edit",
2889                args_sensitive: true,
2890            })
2891            .expect("register exact Edit shadow")
2892            .with_tool(ExactRoutingTool {
2893                name: "Bash",
2894                label: "exact-bash",
2895                args_sensitive: true,
2896            })
2897            .expect("register exact Bash shadow")
2898            .with_tool(ExactRoutingTool {
2899                name: "Glob",
2900                label: "exact-glob",
2901                args_sensitive: true,
2902            })
2903            .expect("register exact Glob shadow")
2904            .with_default_tools()
2905            .build();
2906
2907        let cases = [
2908            ("Read", json!({"path": "/tmp/custom-read"}), "file_path"),
2909            ("Write", json!({"path": "/tmp/custom-write"}), "file_path"),
2910            ("Edit", json!({"path": "/tmp/custom-edit"}), "file_path"),
2911            ("Bash", json!({"cmd": "custom-command"}), "command"),
2912            (
2913                "Glob",
2914                json!({"path": "/tmp/custom-glob", "recursive": true}),
2915                "pattern",
2916            ),
2917        ];
2918
2919        for (name, args, normalized_key) in cases {
2920            let call = make_tool_call(name, args.clone());
2921            assert_eq!(
2922                executor.call_mutability(&call),
2923                crate::ToolMutability::ReadOnly,
2924                "custom {name} classification must see the original args"
2925            );
2926            assert!(
2927                executor.call_concurrency_safe(&call),
2928                "custom {name} classification must remain parallel-safe"
2929            );
2930
2931            let result = executor
2932                .execute(&call)
2933                .await
2934                .unwrap_or_else(|error| panic!("execute custom {name}: {error}"));
2935            let result: serde_json::Value = serde_json::from_str(&result.result).unwrap();
2936            assert_eq!(result["args"], args, "custom {name} args changed");
2937            assert!(result["args"].get(normalized_key).is_none());
2938        }
2939
2940        // Exercise the permission entry point with exact canonical shadows for
2941        // which the central policy has no name-based write/execute rule. The
2942        // same raw args must reach classification and invocation even when a
2943        // checker is installed.
2944        let permission_executor = BuiltinToolExecutorBuilder::new()
2945            .with_tool(ExactRoutingTool {
2946                name: "Read",
2947                label: "permission-read",
2948                args_sensitive: true,
2949            })
2950            .expect("register permission-aware Read shadow")
2951            .with_tool(ExactRoutingTool {
2952                name: "Glob",
2953                label: "permission-glob",
2954                args_sensitive: true,
2955            })
2956            .expect("register permission-aware Glob shadow")
2957            .with_default_tools()
2958            .with_permission_checker(Arc::new(crate::permission::AllowAllPermissionChecker))
2959            .build();
2960        for (name, args) in [
2961            ("Read", json!({"path": "/tmp/permission-read"})),
2962            (
2963                "Glob",
2964                json!({"path": "/tmp/permission-glob", "recursive": true}),
2965            ),
2966        ] {
2967            let call = make_tool_call(name, args.clone());
2968            let ctx = ToolExecutionContext::none(&call.id);
2969            assert!(permission_executor
2970                .check_permissions_for(&call, &ctx)
2971                .await
2972                .expect("permission check")
2973                .is_none());
2974            assert_eq!(
2975                permission_executor.call_mutability(&call),
2976                crate::ToolMutability::ReadOnly
2977            );
2978            assert!(permission_executor.call_concurrency_safe(&call));
2979            let result = permission_executor.execute(&call).await.unwrap();
2980            let result: serde_json::Value = serde_json::from_str(&result.result).unwrap();
2981            assert_eq!(result["args"], args);
2982        }
2983    }
2984
2985    #[tokio::test]
2986    async fn exact_apply_patch_keeps_original_args_and_classification() {
2987        let executor = BuiltinToolExecutorBuilder::new()
2988            .with_filesystem_tool("Edit")
2989            .expect("register builtin Edit")
2990            .with_tool(ExactRoutingTool {
2991                name: "apply_patch",
2992                label: "exact-apply-patch",
2993                args_sensitive: true,
2994            })
2995            .expect("register exact apply_patch shadow")
2996            .build();
2997        let call = make_tool_call("apply_patch", json!({"path": "/tmp/exact-shadow"}));
2998
2999        let (mutability, parallel_safe) = executor.call_parallel_classification(&call);
3000        assert_eq!(mutability, crate::ToolMutability::ReadOnly);
3001        assert!(parallel_safe);
3002
3003        let result = executor.execute(&call).await.expect("execute exact shadow");
3004        let result: serde_json::Value = serde_json::from_str(&result.result).unwrap();
3005        assert_eq!(result["label"], "exact-apply-patch");
3006        assert_eq!(result["args"]["path"], "/tmp/exact-shadow");
3007        assert!(result["args"].get("file_path").is_none());
3008    }
3009
3010    #[tokio::test]
3011    async fn exact_permission_seam_preserves_default_apply_patch_builtin_provenance() {
3012        let executor = BuiltinToolExecutorBuilder::new()
3013            .with_filesystem_tool("Edit")
3014            .expect("register builtin Edit")
3015            .with_permission_checker(Arc::new(crate::permission::AllowAllPermissionChecker))
3016            .build();
3017        let raw_args = json!({
3018            "path": "/tmp/exact-permission-apply-patch.txt",
3019            "old_string": "before",
3020            "new_string": "after"
3021        });
3022        let call = make_tool_call("default::apply_patch", raw_args.clone());
3023        let ctx = ToolExecutionContext {
3024            pre_parsed_args: Some(&raw_args),
3025            ..ToolExecutionContext::none(&call.id)
3026        };
3027
3028        assert!(executor
3029            .check_permissions_for_exact(&call, "Edit", &ctx)
3030            .await
3031            .expect("normalized builtin permission check")
3032            .is_none());
3033        assert_eq!(call.function.name, "default::apply_patch");
3034        assert_eq!(
3035            serde_json::from_str::<serde_json::Value>(&call.function.arguments).unwrap(),
3036            raw_args
3037        );
3038    }
3039
3040    #[tokio::test]
3041    async fn unshadowed_alias_and_namespace_keep_legacy_argument_compatibility() {
3042        let dir = tempfile::tempdir().unwrap();
3043        let path = dir.path().join("legacy-alias.txt");
3044        fs::write(&path, "before").await.unwrap();
3045        let executor = BuiltinToolExecutorBuilder::new()
3046            .with_filesystem_tool("Edit")
3047            .expect("register builtin Edit")
3048            .with_permission_checker(Arc::new(crate::permission::AllowAllPermissionChecker))
3049            .build();
3050
3051        let result = executor
3052            .execute(&make_tool_call(
3053                "default::apply_patch",
3054                json!({
3055                    "path": path,
3056                    "old_string": "before",
3057                    "new_string": "after"
3058                }),
3059            ))
3060            .await
3061            .expect("execute unshadowed alias");
3062        assert!(result.success);
3063        assert_eq!(fs::read_to_string(path).await.unwrap(), "after");
3064    }
3065
3066    // ---- issue #106: parse tool args once on the execute path -------------
3067
3068    /// A tool that echoes back the `v` field of the args it was invoked with, so
3069    /// a test can observe *which* parsed value reached the tool.
3070    struct EchoArgsTool;
3071
3072    #[async_trait]
3073    impl Tool for EchoArgsTool {
3074        fn name(&self) -> &str {
3075            "echo_args"
3076        }
3077        fn description(&self) -> &str {
3078            "echoes the `v` arg"
3079        }
3080        fn parameters_schema(&self) -> serde_json::Value {
3081            json!({"type":"object","properties":{"v":{"type":"string"}}})
3082        }
3083        async fn invoke(
3084            &self,
3085            args: serde_json::Value,
3086            _ctx: ToolCtx,
3087        ) -> Result<ToolOutcome, ToolError> {
3088            let v = args
3089                .get("v")
3090                .and_then(serde_json::Value::as_str)
3091                .unwrap_or("<none>")
3092                .to_string();
3093            Ok(ToolOutcome::Completed(ToolResult {
3094                success: true,
3095                result: v,
3096                display_preference: None,
3097                images: Vec::new(),
3098            }))
3099        }
3100    }
3101
3102    fn ctx_with_pre_parsed<'a>(
3103        call_id: &'a str,
3104        pre_parsed: Option<&'a serde_json::Value>,
3105    ) -> ToolExecutionContext<'a> {
3106        ToolExecutionContext {
3107            executing_supervisor: None,
3108            session_id: Some("s-106"),
3109            root_session_id: None,
3110            tool_call_id: call_id,
3111            event_tx: None,
3112            available_tool_schemas: None,
3113            bypass_permissions: false,
3114            auto_approve_permissions: false,
3115            plan_read_only: false,
3116            can_async_resume: false,
3117            bash_completion_sink: None,
3118            pre_parsed_args: pre_parsed,
3119        }
3120    }
3121
3122    #[tokio::test]
3123    async fn execute_with_context_reuses_pre_parsed_args_without_reparsing() {
3124        // The raw `arguments` string and the threaded `pre_parsed_args` Value
3125        // deliberately disagree. If the executor honored the contract (parse
3126        // once at the dispatch site, reuse downstream), the tool sees the
3127        // pre-parsed value; if it re-parsed the raw string it would see "raw".
3128        // This is the load-bearing proof that the second parse was eliminated.
3129        let executor = BuiltinToolExecutor::new();
3130        executor.register_tool(EchoArgsTool).expect("register echo");
3131
3132        let call = make_tool_call("echo_args", json!({"v": "raw"}));
3133        let pre_parsed = json!({"v": "preparsed"});
3134        let ctx = ctx_with_pre_parsed(&call.id, Some(&pre_parsed));
3135
3136        let result = executor
3137            .execute_with_context(&call, ctx)
3138            .await
3139            .expect("execute echo tool");
3140        assert_eq!(
3141            result.result, "preparsed",
3142            "executor must reuse pre_parsed_args, not re-parse the raw string"
3143        );
3144    }
3145
3146    #[tokio::test]
3147    async fn execute_with_context_parses_raw_when_no_pre_parsed_args() {
3148        // Without a threaded value (the `execute` entry point / tests / a loop
3149        // that parsed with a different parser), the executor falls back to
3150        // parsing the raw string exactly as before — behavior preserved.
3151        let executor = BuiltinToolExecutor::new();
3152        executor.register_tool(EchoArgsTool).expect("register echo");
3153
3154        let call = make_tool_call("echo_args", json!({"v": "raw"}));
3155        let ctx = ctx_with_pre_parsed(&call.id, None);
3156
3157        let result = executor
3158            .execute_with_context(&call, ctx)
3159            .await
3160            .expect("execute echo tool");
3161        assert_eq!(
3162            result.result, "raw",
3163            "without pre_parsed_args the executor parses the raw string as before"
3164        );
3165    }
3166
3167    #[tokio::test]
3168    async fn execute_with_context_malformed_args_repair_unchanged_without_pre_parsed() {
3169        // Malformed (truncated) JSON must still be auto-repaired by the
3170        // fallback parse when no pre-parsed value is threaded — the existing
3171        // error/leniency behavior is untouched by the dedup.
3172        let dir = tempfile::tempdir().unwrap();
3173        let path = dir.path().join("recovered-no-preparsed.txt");
3174        let malformed_args = format!(
3175            r#"{{"file_path":"{}","content":"recovered content""#,
3176            path.display()
3177        );
3178
3179        let executor = BuiltinToolExecutor::new();
3180        let call = make_tool_call_with_raw_args("Write", &malformed_args);
3181        let ctx = ctx_with_pre_parsed(&call.id, None);
3182
3183        let result = executor
3184            .execute_with_context(&call, ctx)
3185            .await
3186            .expect("truncated JSON should be auto-repaired");
3187        assert!(result.success);
3188        let written = fs::read_to_string(&path).await.expect("file written");
3189        assert_eq!(written, "recovered content");
3190    }
3191
3192    #[tokio::test]
3193    async fn successful_write_emits_one_bounded_file_changed_event() {
3194        let dir = tempfile::tempdir().unwrap();
3195        let path = dir.path().join("write-event.txt");
3196        let path_string = path.to_string_lossy().into_owned();
3197        let padded_path = format!("  {path_string}  ");
3198        let recorder = Arc::new(InMemoryToolEventRecorder::new(4).unwrap());
3199        let executor = BuiltinToolExecutorBuilder::new()
3200            .with_filesystem_tool("Write")
3201            .unwrap()
3202            .with_tool_event_publisher(recorder.clone())
3203            .build();
3204        let call = make_tool_call_with_id(
3205            "write-call",
3206            "Write",
3207            json!({"file_path": padded_path, "content": "written"}),
3208        );
3209
3210        let result = executor
3211            .execute_with_context(
3212                &call,
3213                tool_event_context(&call, Some("write-session"), Some("write-root-session")),
3214            )
3215            .await
3216            .unwrap();
3217
3218        assert!(result.success);
3219        assert_eq!(fs::read_to_string(path).await.unwrap(), "written");
3220        assert_single_file_changed(
3221            &recorder,
3222            "write-session",
3223            "write-root-session",
3224            "Write",
3225            "write-call",
3226            &path_string,
3227        );
3228    }
3229
3230    #[tokio::test]
3231    async fn successful_edit_emits_one_bounded_file_changed_event() {
3232        let dir = tempfile::tempdir().unwrap();
3233        let path = dir.path().join("edit-event.txt");
3234        fs::write(&path, "before\n").await.unwrap();
3235        let path_string = path.to_string_lossy().into_owned();
3236        let padded_path = format!(" {path_string} ");
3237        let recorder = Arc::new(InMemoryToolEventRecorder::new(4).unwrap());
3238        let executor = BuiltinToolExecutor::new().with_tool_event_publisher(recorder.clone());
3239        let read =
3240            make_tool_call_with_id("edit-read-call", "Read", json!({"file_path": padded_path}));
3241        executor
3242            .execute_with_context(
3243                &read,
3244                tool_event_context(&read, Some("edit-session"), Some("edit-root-session")),
3245            )
3246            .await
3247            .unwrap();
3248        assert!(recorder.try_snapshot().unwrap().is_empty());
3249
3250        let edit = make_tool_call_with_id(
3251            "edit-call",
3252            "Edit",
3253            json!({
3254                "file_path": format!(" {path_string} "),
3255                "old_string": "before",
3256                "new_string": "after"
3257            }),
3258        );
3259        let result = executor
3260            .execute_with_context(
3261                &edit,
3262                tool_event_context(&edit, Some("edit-session"), Some("edit-root-session")),
3263            )
3264            .await
3265            .unwrap();
3266
3267        assert!(result.success);
3268        assert_eq!(fs::read_to_string(path).await.unwrap(), "after\n");
3269        assert_single_file_changed(
3270            &recorder,
3271            "edit-session",
3272            "edit-root-session",
3273            "Edit",
3274            "edit-call",
3275            &path_string,
3276        );
3277    }
3278
3279    #[tokio::test]
3280    async fn successful_apply_patch_alias_emits_canonical_edit_with_original_call_id() {
3281        let dir = tempfile::tempdir().unwrap();
3282        let path = dir.path().join("apply-patch-event.txt");
3283        fs::write(&path, "alpha\nbeta\n").await.unwrap();
3284        let path_string = path.to_string_lossy().into_owned();
3285        let recorder = Arc::new(InMemoryToolEventRecorder::new(4).unwrap());
3286        let executor = BuiltinToolExecutor::new().with_tool_event_publisher(recorder.clone());
3287        let read = make_tool_call_with_id(
3288            "apply-patch-read-call",
3289            "Read",
3290            json!({"file_path": path_string}),
3291        );
3292        executor
3293            .execute_with_context(
3294                &read,
3295                tool_event_context(&read, Some("alias-session"), Some("alias-root-session")),
3296            )
3297            .await
3298            .unwrap();
3299
3300        let edit = make_tool_call_with_id(
3301            "model-original-alias-call",
3302            "apply_patch",
3303            json!({
3304                "path": format!("  {path_string}  "),
3305                "old_string": "beta",
3306                "new_string": "BETA"
3307            }),
3308        );
3309        let result = executor
3310            .execute_with_context(
3311                &edit,
3312                tool_event_context(&edit, Some("alias-session"), Some("alias-root-session")),
3313            )
3314            .await
3315            .unwrap();
3316
3317        assert!(result.success);
3318        assert_eq!(fs::read_to_string(path).await.unwrap(), "alpha\nBETA\n");
3319        assert_single_file_changed(
3320            &recorder,
3321            "alias-session",
3322            "alias-root-session",
3323            "Edit",
3324            "model-original-alias-call",
3325            &path_string,
3326        );
3327    }
3328
3329    #[tokio::test]
3330    async fn successful_notebook_edit_emits_one_bounded_file_changed_event() {
3331        let dir = tempfile::tempdir().unwrap();
3332        let path = dir.path().join("notebook-event.ipynb");
3333        fs::write(
3334            &path,
3335            r#"{"cells":[],"metadata":{},"nbformat":4,"nbformat_minor":5}"#,
3336        )
3337        .await
3338        .unwrap();
3339        let path_string = path.to_string_lossy().into_owned();
3340        let recorder = Arc::new(InMemoryToolEventRecorder::new(4).unwrap());
3341        let executor = BuiltinToolExecutorBuilder::new()
3342            .with_filesystem_tool("NotebookEdit")
3343            .unwrap()
3344            .with_tool_event_publisher(recorder.clone())
3345            .build();
3346        let call = make_tool_call_with_id(
3347            "notebook-call",
3348            "NotebookEdit",
3349            json!({
3350                "notebook_path": format!(" {path_string} "),
3351                "new_source": "print('hello')",
3352                "cell_type": "code",
3353                "edit_mode": "insert"
3354            }),
3355        );
3356
3357        let result = executor
3358            .execute_with_context(
3359                &call,
3360                tool_event_context(
3361                    &call,
3362                    Some("notebook-session"),
3363                    Some("notebook-root-session"),
3364                ),
3365            )
3366            .await
3367            .unwrap();
3368
3369        assert!(result.success);
3370        assert_single_file_changed(
3371            &recorder,
3372            "notebook-session",
3373            "notebook-root-session",
3374            "NotebookEdit",
3375            "notebook-call",
3376            &path_string,
3377        );
3378    }
3379
3380    #[cfg(unix)]
3381    #[tokio::test]
3382    async fn write_through_intermediate_symlink_fails_and_emits_zero_events() {
3383        use std::os::unix::fs::symlink;
3384
3385        let workspace = tempfile::tempdir().unwrap();
3386        let external = tempfile::tempdir().unwrap();
3387        let linked_dir = workspace.path().join("linked");
3388        symlink(external.path(), &linked_dir).unwrap();
3389        let target = linked_dir.join("write.txt");
3390        let recorder = Arc::new(InMemoryToolEventRecorder::new(4).unwrap());
3391        let executor = BuiltinToolExecutorBuilder::new()
3392            .with_filesystem_tool("Write")
3393            .unwrap()
3394            .with_tool_event_publisher(recorder.clone())
3395            .build();
3396        let call = make_tool_call_with_id(
3397            "symlink-write",
3398            "Write",
3399            json!({"file_path": target, "content": "must-not-write"}),
3400        );
3401
3402        let result = executor
3403            .execute_with_context(
3404                &call,
3405                tool_event_context(&call, Some("symlink-session"), Some("symlink-root")),
3406            )
3407            .await;
3408        assert!(
3409            result.is_err() || result.as_ref().is_ok_and(|result| !result.success),
3410            "Write must fail closed through an intermediate symlink"
3411        );
3412        assert!(!external.path().join("write.txt").exists());
3413        assert!(recorder.try_snapshot().unwrap().is_empty());
3414    }
3415
3416    #[cfg(unix)]
3417    #[tokio::test]
3418    async fn edit_of_symlinked_file_fails_and_emits_zero_events() {
3419        use std::os::unix::fs::symlink;
3420
3421        let dir = tempfile::tempdir().unwrap();
3422        let real = dir.path().join("real.txt");
3423        let linked = dir.path().join("linked.txt");
3424        fs::write(&real, "before\n").await.unwrap();
3425        symlink(&real, &linked).unwrap();
3426        let recorder = Arc::new(InMemoryToolEventRecorder::new(4).unwrap());
3427        let executor = BuiltinToolExecutor::new().with_tool_event_publisher(recorder.clone());
3428        let read =
3429            make_tool_call_with_id("symlink-edit-read", "Read", json!({"file_path": linked}));
3430        let _ = executor
3431            .execute_with_context(
3432                &read,
3433                tool_event_context(&read, Some("symlink-session"), Some("symlink-root")),
3434            )
3435            .await;
3436        let edit = make_tool_call_with_id(
3437            "symlink-edit",
3438            "Edit",
3439            json!({
3440                "file_path": linked,
3441                "old_string": "before",
3442                "new_string": "after"
3443            }),
3444        );
3445
3446        let result = executor
3447            .execute_with_context(
3448                &edit,
3449                tool_event_context(&edit, Some("symlink-session"), Some("symlink-root")),
3450            )
3451            .await;
3452        assert!(
3453            result.is_err() || result.as_ref().is_ok_and(|result| !result.success),
3454            "Edit must fail closed for a symlinked final file"
3455        );
3456        assert_eq!(fs::read_to_string(&real).await.unwrap(), "before\n");
3457        assert!(recorder.try_snapshot().unwrap().is_empty());
3458    }
3459
3460    #[cfg(unix)]
3461    #[tokio::test]
3462    async fn notebook_edit_through_intermediate_symlink_fails_and_emits_zero_events() {
3463        use std::os::unix::fs::symlink;
3464
3465        let workspace = tempfile::tempdir().unwrap();
3466        let external = tempfile::tempdir().unwrap();
3467        let real_notebook = external.path().join("real.ipynb");
3468        let original = r#"{"cells":[],"metadata":{},"nbformat":4,"nbformat_minor":5}"#;
3469        fs::write(&real_notebook, original).await.unwrap();
3470        let linked_dir = workspace.path().join("linked");
3471        symlink(external.path(), &linked_dir).unwrap();
3472        let recorder = Arc::new(InMemoryToolEventRecorder::new(4).unwrap());
3473        let executor = BuiltinToolExecutorBuilder::new()
3474            .with_filesystem_tool("NotebookEdit")
3475            .unwrap()
3476            .with_tool_event_publisher(recorder.clone())
3477            .build();
3478        let call = make_tool_call_with_id(
3479            "symlink-notebook",
3480            "NotebookEdit",
3481            json!({
3482                "notebook_path": linked_dir.join("real.ipynb"),
3483                "new_source": "print('must not write')",
3484                "cell_type": "code",
3485                "edit_mode": "insert"
3486            }),
3487        );
3488
3489        let result = executor
3490            .execute_with_context(
3491                &call,
3492                tool_event_context(&call, Some("symlink-session"), Some("symlink-root")),
3493            )
3494            .await;
3495        assert!(
3496            result.is_err() || result.as_ref().is_ok_and(|result| !result.success),
3497            "NotebookEdit must fail closed through an intermediate symlink"
3498        );
3499        assert_eq!(fs::read_to_string(&real_notebook).await.unwrap(), original);
3500        assert!(recorder.try_snapshot().unwrap().is_empty());
3501    }
3502
3503    #[tokio::test]
3504    async fn failed_and_non_successful_mutations_emit_no_event() {
3505        let recorder = Arc::new(InMemoryToolEventRecorder::new(4).unwrap());
3506        let executor = BuiltinToolExecutorBuilder::new()
3507            .with_filesystem_tool("Write")
3508            .unwrap()
3509            .with_tool_event_publisher(recorder.clone())
3510            .build();
3511        let failed = make_tool_call_with_id(
3512            "failed-write-call",
3513            "Write",
3514            json!({"file_path": "relative.txt", "content": "never"}),
3515        );
3516        assert!(executor
3517            .execute_with_context(
3518                &failed,
3519                tool_event_context(
3520                    &failed,
3521                    Some("failure-session"),
3522                    Some("failure-root-session"),
3523                ),
3524            )
3525            .await
3526            .is_err());
3527        assert!(recorder.try_snapshot().unwrap().is_empty());
3528
3529        let completed_false = marked_stub_write_executor(false, recorder.clone());
3530        let call = make_tool_call_with_id(
3531            "completed-false-call",
3532            "Write",
3533            json!({"file_path": "/valid/event/path.txt"}),
3534        );
3535        let result = completed_false
3536            .execute_with_context(
3537                &call,
3538                tool_event_context(&call, Some("failure-session"), Some("failure-root-session")),
3539            )
3540            .await
3541            .unwrap();
3542        assert!(!result.success);
3543        assert!(recorder.try_snapshot().unwrap().is_empty());
3544    }
3545
3546    #[tokio::test]
3547    async fn committed_postverify_failure_emits_no_tool_event() {
3548        let dir = tempfile::tempdir().unwrap();
3549        let path = dir.path().join("postverify-conflict.txt");
3550        fs::write(&path, "before").await.unwrap();
3551        let path_string = path.to_string_lossy().into_owned();
3552        let session_id = format!("event-conflict-{}", uuid::Uuid::new_v4());
3553        let recorder = Arc::new(InMemoryToolEventRecorder::new(4).unwrap());
3554        let executor =
3555            Arc::new(BuiltinToolExecutor::new().with_tool_event_publisher(recorder.clone()));
3556
3557        let initial_read = make_tool_call_with_id(
3558            "conflict-initial-read",
3559            "Read",
3560            json!({"file_path": path_string}),
3561        );
3562        executor
3563            .execute_with_context(
3564                &initial_read,
3565                tool_event_context(
3566                    &initial_read,
3567                    Some(&session_id),
3568                    Some("conflict-root-session"),
3569                ),
3570            )
3571            .await
3572            .unwrap();
3573        let (advance_reached, resume_advance) =
3574            crate::tools::read_tracker::pause_next_advance_for_test(&session_id, &path_string)
3575                .await;
3576
3577        let writer_executor = executor.clone();
3578        let writer_session = session_id.clone();
3579        let writer_path = path_string.clone();
3580        let writer = tokio::spawn(async move {
3581            let call = make_tool_call_with_id(
3582                "conflict-write-call",
3583                "Write",
3584                json!({"file_path": writer_path, "content": "intended"}),
3585            );
3586            writer_executor
3587                .execute_with_context(
3588                    &call,
3589                    tool_event_context(&call, Some(&writer_session), Some("conflict-root-session")),
3590                )
3591                .await
3592        });
3593
3594        tokio::time::timeout(
3595            std::time::Duration::from_secs(5),
3596            advance_reached.notified(),
3597        )
3598        .await
3599        .expect("Write did not reach post-write baseline advancement");
3600        fs::write(&path, "other").await.unwrap();
3601        let concurrent_read = make_tool_call_with_id(
3602            "conflict-concurrent-read",
3603            "Read",
3604            json!({"file_path": path_string}),
3605        );
3606        executor
3607            .execute_with_context(
3608                &concurrent_read,
3609                tool_event_context(
3610                    &concurrent_read,
3611                    Some(&session_id),
3612                    Some("conflict-root-session"),
3613                ),
3614            )
3615            .await
3616            .unwrap();
3617        fs::write(&path, "intended").await.unwrap();
3618        resume_advance.notify_one();
3619
3620        let outcome = tokio::time::timeout(std::time::Duration::from_secs(5), writer)
3621            .await
3622            .expect("Write did not resume")
3623            .unwrap();
3624        assert!(
3625            matches!(outcome, Err(ToolError::Execution(ref message)) if message.contains("Write committed")),
3626            "committed postverify conflict must stay an error: {outcome:?}"
3627        );
3628        assert_eq!(fs::read_to_string(path).await.unwrap(), "intended");
3629        assert!(
3630            recorder.try_snapshot().unwrap().is_empty(),
3631            "an on-disk mutation is not a successful tool outcome"
3632        );
3633    }
3634
3635    #[tokio::test]
3636    async fn permission_pause_does_not_publish_a_success_event() {
3637        let dir = tempfile::tempdir().unwrap();
3638        let path = dir.path().join("approval-gated.txt");
3639        let config = Arc::new(crate::permission::PermissionConfig::new());
3640        config.set_ask_rules([format!("Write({}/**)", dir.path().display())]);
3641        config.register_session_workspace(
3642            "approval-session",
3643            dir.path().to_string_lossy().into_owned(),
3644        );
3645        let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
3646        let recorder = Arc::new(InMemoryToolEventRecorder::new(4).unwrap());
3647        let executor = BuiltinToolExecutorBuilder::new()
3648            .with_filesystem_tool("Write")
3649            .unwrap()
3650            .with_permission_checker(checker)
3651            .with_tool_event_publisher(recorder.clone())
3652            .build();
3653        let call = make_tool_call_with_id(
3654            "approval-call",
3655            "Write",
3656            json!({"file_path": path, "content": "not-yet"}),
3657        );
3658        let (event_tx, _event_rx) = mpsc::channel(4);
3659        let mut ctx = tool_event_context(
3660            &call,
3661            Some("approval-session"),
3662            Some("approval-root-session"),
3663        );
3664        ctx.event_tx = Some(&event_tx);
3665
3666        let result = executor.execute_with_context(&call, ctx).await.unwrap();
3667        assert!(
3668            result.success,
3669            "approval pause is a synthetic success result"
3670        );
3671        assert_eq!(
3672            result.display_preference.as_deref(),
3673            Some("request_permissions")
3674        );
3675        assert!(!path.exists(), "permission pause must not invoke Write");
3676        assert!(recorder.try_snapshot().unwrap().is_empty());
3677    }
3678
3679    #[tokio::test]
3680    async fn missing_authority_or_oversize_path_fails_closed_without_event() {
3681        let recorder = Arc::new(InMemoryToolEventRecorder::new(4).unwrap());
3682        let executor = marked_stub_write_executor(true, recorder.clone());
3683
3684        let missing_session = make_tool_call_with_id(
3685            "missing-session-call",
3686            "Write",
3687            json!({"file_path": "/bounded/path.txt"}),
3688        );
3689        assert!(
3690            executor
3691                .execute_with_context(
3692                    &missing_session,
3693                    tool_event_context(&missing_session, None, Some("authority-root-session"),),
3694                )
3695                .await
3696                .unwrap()
3697                .success
3698        );
3699
3700        let missing_root = make_tool_call_with_id(
3701            "missing-root-call",
3702            "Write",
3703            json!({"file_path": "/bounded/path.txt"}),
3704        );
3705        assert!(
3706            executor
3707                .execute_with_context(
3708                    &missing_root,
3709                    tool_event_context(&missing_root, Some("authority-session"), None),
3710                )
3711                .await
3712                .unwrap()
3713                .success
3714        );
3715
3716        let oversize_path = make_tool_call_with_id(
3717            "oversize-path-call",
3718            "Write",
3719            json!({"file_path": "x".repeat(MAX_TOOL_EVENT_PATH_BYTES + 1)}),
3720        );
3721        assert!(
3722            executor
3723                .execute_with_context(
3724                    &oversize_path,
3725                    tool_event_context(
3726                        &oversize_path,
3727                        Some("authority-session"),
3728                        Some("authority-root-session"),
3729                    ),
3730                )
3731                .await
3732                .unwrap()
3733                .success
3734        );
3735
3736        assert!(recorder.try_snapshot().unwrap().is_empty());
3737    }
3738
3739    #[tokio::test]
3740    async fn custom_write_name_never_acquires_builtin_event_provenance() {
3741        let recorder = Arc::new(InMemoryToolEventRecorder::new(4).unwrap());
3742        let registry = ToolRegistry::new();
3743        registry.register(StubWriteTool { success: true }).unwrap();
3744        let from_registry = BuiltinToolExecutor::with_registry(registry)
3745            .with_tool_event_publisher(recorder.clone());
3746        let first = make_tool_call_with_id(
3747            "spoof-registry-call",
3748            "Write",
3749            json!({"file_path": "/spoof/path.txt"}),
3750        );
3751        assert!(
3752            from_registry
3753                .execute_with_context(
3754                    &first,
3755                    tool_event_context(&first, Some("spoof-session"), Some("spoof-root-session"),),
3756                )
3757                .await
3758                .unwrap()
3759                .success
3760        );
3761
3762        let custom_before_defaults = BuiltinToolExecutorBuilder::new()
3763            .with_tool(StubWriteTool { success: true })
3764            .unwrap()
3765            .with_default_tools()
3766            .with_tool_event_publisher(recorder.clone())
3767            .build();
3768        let second = make_tool_call_with_id(
3769            "spoof-builder-order-call",
3770            "Write",
3771            json!({"file_path": "/spoof/path.txt"}),
3772        );
3773        assert!(
3774            custom_before_defaults
3775                .execute_with_context(
3776                    &second,
3777                    tool_event_context(&second, Some("spoof-session"), Some("spoof-root-session"),),
3778                )
3779                .await
3780                .unwrap()
3781                .success
3782        );
3783
3784        let replaced_builtin =
3785            BuiltinToolExecutor::new().with_tool_event_publisher(recorder.clone());
3786        assert!(replaced_builtin.registry().unregister("Write"));
3787        replaced_builtin
3788            .register_tool(StubWriteTool { success: true })
3789            .unwrap();
3790        let third = make_tool_call_with_id(
3791            "spoof-replaced-builtin-call",
3792            "Write",
3793            json!({"file_path": "/spoof/path.txt"}),
3794        );
3795        assert!(
3796            replaced_builtin
3797                .execute_with_context(
3798                    &third,
3799                    tool_event_context(&third, Some("spoof-session"), Some("spoof-root-session"),),
3800                )
3801                .await
3802                .unwrap()
3803                .success
3804        );
3805
3806        assert!(recorder.try_snapshot().unwrap().is_empty());
3807    }
3808
3809    #[tokio::test]
3810    async fn publisher_rejection_or_panic_never_changes_successful_tool_result() {
3811        let full = Arc::new(InMemoryToolEventRecorder::new(1).unwrap());
3812        full.try_publish(seed_event("seed-full")).unwrap();
3813        assert_real_write_succeeds_with_publisher(full.clone(), "full").await;
3814        let retained = full.try_snapshot().unwrap();
3815        assert_eq!(retained.len(), 1);
3816        assert_eq!(retained[0].context.tool_call_id, "seed-full");
3817
3818        let publishers: Vec<(&str, Arc<dyn ToolEventPublisher>)> = vec![
3819            (
3820                "busy",
3821                Arc::new(ReturningPublisher(ToolEventPublishError::Busy)),
3822            ),
3823            (
3824                "poisoned",
3825                Arc::new(ReturningPublisher(ToolEventPublishError::Poisoned)),
3826            ),
3827            (
3828                "failed",
3829                Arc::new(ReturningPublisher(ToolEventPublishError::Failed(
3830                    "sink unavailable".to_string(),
3831                ))),
3832            ),
3833            ("enabled-panic", Arc::new(IsEnabledPanicPublisher)),
3834            ("publish-panic", Arc::new(TryPublishPanicPublisher)),
3835        ];
3836        for (label, publisher) in publishers {
3837            assert_real_write_succeeds_with_publisher(publisher, label).await;
3838        }
3839    }
3840}