Skip to main content

bamboo_tools/
executor.rs

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