Skip to main content

bamboo_tools/
executor.rs

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