Skip to main content

bamboo_tools/
executor.rs

1use std::sync::Arc;
2
3use async_trait::async_trait;
4use bamboo_agent_core::{
5    normalize_tool_name, parse_tool_args_best_effort, Tool, ToolCall, ToolError,
6    ToolExecutionContext, ToolExecutor, ToolOutcome, ToolResult, ToolSchema,
7};
8use bamboo_domain::tool_names::{normalize_builtin_alias, resolve_alias};
9
10use crate::guide::{context::GuideBuildContext, EnhancedPromptBuilder, ToolGuide};
11use crate::permission::{check_permissions, PermissionChecker, PermissionError};
12use crate::tools::{
13    BashInputTool, BashOutputTool, BashTool, ConclusionWithOptionsTool, EditTool,
14    EnterPlanModeTool, ExitPlanModeTool, GetFileInfoTool, GlobTool, GrepTool, JsReplTool,
15    KillShellTool, NotebookEditTool, ReadTool, RequestPermissionsTool, SessionNoteTool, SleepTool,
16    TaskTool, ToolRegistry, UpdateGoalTool, WebFetchTool, WebSearchTool, WorkspaceTool, WriteTool,
17};
18use bamboo_llm::Config;
19use tokio::sync::RwLock;
20
21fn preview_for_log(value: &str, max_chars: usize) -> String {
22    let mut iter = value.chars();
23    let mut preview = String::new();
24    for _ in 0..max_chars {
25        match iter.next() {
26            Some(ch) => preview.push(ch),
27            None => break,
28        }
29    }
30    if iter.next().is_some() {
31        preview.push_str("...");
32    }
33    preview.replace('\n', "\\n").replace('\r', "\\r")
34}
35
36fn copy_legacy_arg_if_missing(
37    args: &mut serde_json::Map<String, serde_json::Value>,
38    from: &str,
39    to: &str,
40) {
41    if args.contains_key(to) {
42        return;
43    }
44    if let Some(value) = args.get(from).cloned() {
45        args.insert(to.to_string(), value);
46    }
47}
48
49fn normalize_legacy_builtin_args(
50    raw_tool_name: &str,
51    args: &mut serde_json::Map<String, serde_json::Value>,
52) {
53    match raw_tool_name {
54        "read_file" | "write_file" | "Read" | "Write" | "apply_patch" => {
55            copy_legacy_arg_if_missing(args, "path", "file_path");
56        }
57        "execute_command" | "Bash" => {
58            copy_legacy_arg_if_missing(args, "cmd", "command");
59        }
60        "list_directory" | "Glob" => {
61            let should_default_pattern = raw_tool_name == "list_directory"
62                || args.contains_key("path")
63                || args.contains_key("recursive");
64            if should_default_pattern && !args.contains_key("pattern") {
65                let recursive = args
66                    .get("recursive")
67                    .and_then(serde_json::Value::as_bool)
68                    .unwrap_or(false);
69                let pattern = if recursive { "**/*" } else { "*" };
70                args.insert(
71                    "pattern".to_string(),
72                    serde_json::Value::String(pattern.to_string()),
73                );
74            }
75            args.remove("recursive");
76        }
77        _ => {}
78    }
79}
80
81fn resolve_registered_tool_name(registry: &ToolRegistry, raw_tool_name: &str) -> String {
82    if registry.get(raw_tool_name).is_some() {
83        return raw_tool_name.to_string();
84    }
85
86    let aliased = normalize_builtin_alias(raw_tool_name);
87    if registry.get(aliased).is_some() {
88        return aliased.to_string();
89    }
90
91    resolve_alias(aliased).unwrap_or(aliased).to_string()
92}
93
94/// Built-in tool executor that uses ToolRegistry for dynamic dispatch
95pub struct BuiltinToolExecutor {
96    registry: ToolRegistry,
97    permission_checker: Option<Arc<dyn PermissionChecker>>,
98}
99
100impl BuiltinToolExecutor {
101    /// Creates a new executor with all built-in tools registered
102    pub fn new() -> Self {
103        let registry = ToolRegistry::new();
104        Self::register_builtin_tools(&registry, None);
105        Self {
106            registry,
107            permission_checker: None,
108        }
109    }
110
111    /// Creates a new executor with a permission checker
112    pub fn new_with_permissions(permission_checker: Arc<dyn PermissionChecker>) -> Self {
113        let registry = ToolRegistry::new();
114        Self::register_builtin_tools(&registry, None);
115        Self {
116            registry,
117            permission_checker: Some(permission_checker),
118        }
119    }
120
121    /// Creates a new executor that can read the shared, hot-reloadable config.
122    ///
123    /// Use this when running inside the Bamboo server so tools (notably
124    /// `http_request`) honor proxy settings from `config.json`.
125    pub fn new_with_config(config: Arc<RwLock<Config>>) -> Self {
126        let registry = ToolRegistry::new();
127        Self::register_builtin_tools(&registry, Some(config));
128        Self {
129            registry,
130            permission_checker: None,
131        }
132    }
133
134    /// Creates a new executor with both shared config and a permission checker.
135    pub fn new_with_config_and_permissions(
136        config: Arc<RwLock<Config>>,
137        permission_checker: Arc<dyn PermissionChecker>,
138    ) -> Self {
139        let registry = ToolRegistry::new();
140        Self::register_builtin_tools(&registry, Some(config));
141        Self {
142            registry,
143            permission_checker: Some(permission_checker),
144        }
145    }
146
147    /// Creates a new executor from an existing registry
148    pub fn with_registry(registry: ToolRegistry) -> Self {
149        Self {
150            registry,
151            permission_checker: None,
152        }
153    }
154
155    /// Creates a new executor from an existing registry and permission checker.
156    ///
157    /// This is the dependency-injection counterpart to
158    /// [`new_with_permissions`](Self::new_with_permissions): callers that
159    /// intentionally expose a selected/custom registry can keep the canonical
160    /// permission gate instead of silently dropping it.
161    pub fn with_registry_and_permissions(
162        registry: ToolRegistry,
163        permission_checker: Arc<dyn PermissionChecker>,
164    ) -> Self {
165        Self {
166            registry,
167            permission_checker: Some(permission_checker),
168        }
169    }
170
171    /// Returns a reference to the internal registry
172    pub fn registry(&self) -> &ToolRegistry {
173        &self.registry
174    }
175
176    /// Registers all built-in tools to the given registry
177    fn register_builtin_tools(registry: &ToolRegistry, config: Option<Arc<RwLock<Config>>>) {
178        let _ = config;
179        // NOTE: apply_patch is now an alias for Edit – no separate registration.
180        let _ = registry.register(ConclusionWithOptionsTool::new());
181        let _ = registry.register(BashTool::new());
182        let _ = registry.register(BashInputTool::new());
183        let _ = registry.register(BashOutputTool::new());
184        let _ = registry.register(EditTool::new());
185        let _ = registry.register(EnterPlanModeTool::new());
186        let _ = registry.register(ExitPlanModeTool::new());
187        // NOTE: FileExists is now an alias for GetFileInfo – no separate registration.
188        let _ = registry.register(GetFileInfoTool::new());
189        let _ = registry.register(GlobTool::new());
190        let _ = registry.register(GrepTool::new());
191        let _ = registry.register(UpdateGoalTool::new());
192        let _ = registry.register(JsReplTool::new());
193        let _ = registry.register(KillShellTool::new());
194        let _ = registry.register(SessionNoteTool::new());
195        let _ = registry.register(NotebookEditTool::new());
196        let _ = registry.register(ReadTool::new());
197        let _ = registry.register(RequestPermissionsTool::new());
198        let _ = registry.register(SleepTool::new());
199        let _ = registry.register(TaskTool::new());
200        let _ = registry.register(WebFetchTool::new());
201        let _ = registry.register(WebSearchTool::new());
202        // NOTE: GetCurrentDir + SetWorkspace are now aliases for Workspace.
203        let _ = registry.register(WorkspaceTool::new());
204        let _ = registry.register(WriteTool::new());
205    }
206
207    /// Returns all built-in tool schemas
208    pub fn tool_schemas() -> Vec<ToolSchema> {
209        let registry = ToolRegistry::new();
210        Self::register_builtin_tools(&registry, None);
211        registry.list_tools()
212    }
213
214    /// Registers a custom tool to this executor
215    pub fn register_tool<T: Tool + 'static>(&self, tool: T) -> Result<(), ToolError> {
216        self.registry
217            .register(tool)
218            .map_err(|e| ToolError::Execution(e.to_string()))
219    }
220
221    /// Register a tool with its guide
222    pub fn register_tool_with_guide<T, G>(&self, tool: T, guide: G) -> Result<(), ToolError>
223    where
224        T: Tool + 'static,
225        G: ToolGuide + 'static,
226    {
227        self.registry
228            .register_with_guide(tool, guide)
229            .map_err(|e| ToolError::Execution(e.to_string()))
230    }
231
232    /// Get guide for a tool
233    pub fn get_guide(&self, tool_name: &str) -> Option<Arc<dyn ToolGuide>> {
234        self.registry.get_guide(tool_name)
235    }
236
237    /// Build enhanced prompt for all registered tools
238    pub fn build_enhanced_prompt(&self, context: GuideBuildContext) -> String {
239        EnhancedPromptBuilder::build(Some(&self.registry), &self.registry.list_tools(), &context)
240    }
241}
242
243fn permission_error_to_tool_error(error: PermissionError) -> ToolError {
244    match error {
245        PermissionError::CheckFailed(_) => ToolError::InvalidArguments(error.to_string()),
246        _ => ToolError::Execution(error.to_string()),
247    }
248}
249
250impl Default for BuiltinToolExecutor {
251    fn default() -> Self {
252        Self::new()
253    }
254}
255
256#[async_trait]
257impl ToolExecutor for BuiltinToolExecutor {
258    async fn execute(&self, call: &ToolCall) -> Result<ToolResult, ToolError> {
259        self.execute_with_context(call, ToolExecutionContext::none(&call.id))
260            .await
261    }
262
263    async fn execute_with_context(
264        &self,
265        call: &ToolCall,
266        ctx: ToolExecutionContext<'_>,
267    ) -> Result<ToolResult, ToolError> {
268        self.execute_with_context_outcome(call, ctx)
269            .await
270            .map(ToolOutcome::into_tool_result)
271    }
272
273    async fn execute_with_context_outcome(
274        &self,
275        call: &ToolCall,
276        ctx: ToolExecutionContext<'_>,
277    ) -> Result<ToolOutcome, ToolError> {
278        // Reuse the args the dispatching agent loop already parsed (for the
279        // `ToolStart` event) when it threaded them through the context, instead
280        // of re-parsing the raw JSON string here (issue #106, deferred B1 from
281        // #17). The pre-parsed value is the exact output of
282        // `parse_tool_args_best_effort` on the same input, and that loop already
283        // logged any fallback warning at parse time, so skipping the re-parse is
284        // behavior-preserving. When absent (the `execute` entry point, tests, or
285        // a loop that parsed with a different/stricter parser), fall back to
286        // parsing here exactly as before — including the fallback-warning log.
287        let mut args = if let Some(pre_parsed) = ctx.pre_parsed_args {
288            pre_parsed.clone()
289        } else {
290            let args_raw = call.function.arguments.trim();
291            let (parsed, parse_warning) = parse_tool_args_best_effort(&call.function.arguments);
292            if let Some(warning) = parse_warning {
293                tracing::warn!(
294                    "Builtin tool argument parsing fallback applied: session_id={:?}, tool_call_id={}, tool_name={}, args_len={}, args_preview=\"{}\", warning={}",
295                    ctx.session_id,
296                    call.id,
297                    call.function.name,
298                    args_raw.len(),
299                    preview_for_log(args_raw, 180),
300                    warning
301                );
302            }
303            parsed
304        };
305
306        let raw_tool_name = normalize_tool_name(&call.function.name);
307        if let Some(args_obj) = args.as_object_mut() {
308            normalize_legacy_builtin_args(raw_tool_name, args_obj);
309        }
310
311        let tool_name = resolve_registered_tool_name(&self.registry, raw_tool_name);
312
313        // Look up the tool in the registry
314        let tool = self
315            .registry
316            .get(&tool_name)
317            .ok_or_else(|| ToolError::NotFound(format!("Tool '{}' not found", tool_name)))?;
318
319        // Permission gate. Factored onto the `ToolExecutor` trait
320        // (`check_permissions_for`) so overlay/wrapping executors can run the
321        // exact same check before invoking their own tools (issue #341). Kept
322        // AFTER the registry lookup so a `NotFound` still takes precedence,
323        // exactly as before. `Some(outcome)` is the interactive approval pause
324        // synthesized for a human sink; `Err` is deny / fail-closed.
325        if let Some(outcome) = self.check_permissions_for(call, &ctx).await? {
326            return Ok(outcome);
327        }
328
329        // Rewritten dispatch: build the owned `ToolCtx` at this concrete seam and
330        // call the tool's single `invoke`. Unwrap the `ToolOutcome` back to a
331        // `ToolResult` so the surrounding dispatch/loop is unchanged for now:
332        // `Completed` is the result; `Running`'s synthetic ack IS a `ToolResult`
333        // (preserving background Bash's current behavior); `NeedsHuman` cannot yet
334        // be produced (no tool returns it in this phase). Phase B makes the outcome
335        // authoritative and removes this unwrap.
336        let tool_ctx = ctx.to_tool_ctx();
337        tool.invoke(args, tool_ctx).await
338    }
339
340    /// The real permission gate for built-in tools, extracted from the execute
341    /// path so it is reusable by wrapping executors (issue #341). The behavior is
342    /// byte-for-byte the same block that used to run inline in
343    /// `execute_with_context_outcome`:
344    ///
345    /// - resolves the SAME `tool_name` + `args` the execute path runs with (so
346    ///   the check sees exactly what the tool will run with);
347    /// - "always ask" rules (`requires_forced_confirmation`) force a confirmation
348    ///   even under bypass; everything else is skipped when the session is in
349    ///   bypass-permissions mode;
350    /// - forced confirmations route through `check_or_request_forced` so the
351    ///   active mode/bypass can't suppress the prompt;
352    /// - a `ConfirmationRequired` first tries the cross-process `ApprovalProxy`
353    ///   (a subagent worker forwarding to its host), then the interactive human
354    ///   sink (returning the synthesized approval pause as `Ok(Some(..))`), then
355    ///   fails closed;
356    /// - deny fails closed.
357    ///
358    /// The only mechanical difference from the old inline block: the interactive
359    /// pause is returned as `Ok(Some(outcome))` and a clean pass returns
360    /// `Ok(None)`, so the caller decides whether to run the tool. The fallback
361    /// arg-parse warning is intentionally NOT re-logged here — the execute path
362    /// already logs it once for this call.
363    async fn check_permissions_for(
364        &self,
365        call: &ToolCall,
366        ctx: &ToolExecutionContext<'_>,
367    ) -> Result<Option<ToolOutcome>, ToolError> {
368        let raw_tool_name = normalize_tool_name(&call.function.name);
369        let tool_name = resolve_registered_tool_name(&self.registry, raw_tool_name);
370        if ctx.auto_approve_permissions && tool_name.eq_ignore_ascii_case("request_permissions") {
371            return Err(ToolError::Execution(
372                "Auto mode cannot request expanded permissions; operate within existing hard boundaries"
373                    .to_string(),
374            ));
375        }
376        if ctx.plan_read_only && !crate::orchestrator::plan_mode_allows_tool(&tool_name) {
377            return Err(ToolError::Execution(format!(
378                "Plan mode: {tool_name} operation blocked"
379            )));
380        }
381        let Some(permission_checker) = &self.permission_checker else {
382            return Ok(None);
383        };
384        let hook_permission_override = crate::current_hook_permission_override(&call.id);
385
386        // Mirror the head of `execute_with_context_outcome`: reuse the pre-parsed
387        // args when threaded, apply the legacy-arg normalization, then resolve the
388        // registered/alias tool name. This is what makes the gate see the exact
389        // `tool_name`/`args` the tool will actually run with.
390        let mut args = if let Some(pre_parsed) = ctx.pre_parsed_args {
391            pre_parsed.clone()
392        } else {
393            parse_tool_args_best_effort(&call.function.arguments).0
394        };
395        if let Some(args_obj) = args.as_object_mut() {
396            normalize_legacy_builtin_args(raw_tool_name, args_obj);
397        }
398
399        if let Some(contexts) =
400            check_permissions(&tool_name, &args).map_err(permission_error_to_tool_error)?
401        {
402            let proactive_permission_request =
403                tool_name.eq_ignore_ascii_case("request_permissions");
404            for context in contexts {
405                let resource = context.resource.clone();
406                let operation_summary = context.operation_description.clone();
407                let risk_level = context.risk_level();
408                let permission_type = context.permission_type;
409                let platform_hard_deny = permission_checker.hard_deny_reason(&context);
410                let config = permission_checker.permission_config();
411                let proxy = crate::approval::current_approval_proxy();
412                let request = if let Some(config) = config.as_ref() {
413                    if proactive_permission_request && proxy.is_some() {
414                        return Err(ToolError::Execution(
415                            "request_permissions requires the local typed decision protocol; a boolean approval relay cannot create remembered authority"
416                                .to_string(),
417                        ));
418                    }
419                    // A boolean approval relay can only honor one-shot choices.
420                    // Interactive local sessions support all typed scopes; the
421                    // evaluator omits workspace when no stable identity is known.
422                    let mut supported_decisions = if proxy.is_some() {
423                        crate::permission::PermissionRequest::forced_decisions()
424                    } else {
425                        crate::permission::PermissionRequest::ordinary_decisions(true)
426                    };
427                    if proactive_permission_request {
428                        // AllowOnce is bound to the request_permissions call,
429                        // not the later target operation, so offering it would
430                        // falsely claim authority was granted. Remembered
431                        // scopes remain exact matcher-bound and are replay-safe.
432                        supported_decisions.retain(|decision| {
433                            *decision != crate::permission::PermissionDecisionKind::AllowOnce
434                        });
435                    }
436                    // Workspace-scoped policy is an authority boundary. Tool
437                    // arguments are model-controlled resources and must never
438                    // choose that scope identity; only the workspace registered
439                    // for this stable session may enable AllowWorkspace.
440                    let workspace_path = ctx
441                        .session_id
442                        .and_then(|session_id| config.session_workspace(session_id));
443                    match config.evaluate(crate::permission::PermissionEvaluation {
444                        request_id: call.id.clone(),
445                        session_id: ctx.session_id.unwrap_or_default().to_string(),
446                        workspace_path,
447                        tool_name: tool_name.clone(),
448                        tool_args: args.clone(),
449                        permission_type,
450                        resource: resource.clone(),
451                        operation_summary: operation_summary.clone(),
452                        risk_level,
453                        bypass_requested: ctx.bypass_permissions,
454                        auto_approve_requested: ctx.auto_approve_permissions,
455                        platform_hard_deny,
456                        consume_once: true,
457                        supported_decisions,
458                    }) {
459                        crate::permission::PermissionOutcome::Allow { .. } => continue,
460                        crate::permission::PermissionOutcome::Deny { reason, .. } => {
461                            return Err(ToolError::Execution(reason.message));
462                        }
463                        crate::permission::PermissionOutcome::Ask(request)
464                            if matches!(
465                                hook_permission_override,
466                                Some(crate::HookPermissionOverride::Allow)
467                            ) && !proactive_permission_request
468                                && request.reason_code
469                                    != crate::permission::PermissionReasonCode::HardDangerous =>
470                        {
471                            continue;
472                        }
473                        crate::permission::PermissionOutcome::Ask(request) => request,
474                    }
475                } else {
476                    if proactive_permission_request {
477                        return Err(ToolError::Execution(
478                            "request_permissions requires a typed PermissionConfig and cannot fall back to a display-string approval"
479                                .to_string(),
480                        ));
481                    }
482                    // Compatibility path for custom checkers that do not expose a
483                    // typed config. It remains one-shot only and fail-closed.
484                    if let Some(reason) = platform_hard_deny {
485                        return Err(ToolError::Execution(reason));
486                    }
487                    let force_ask =
488                        permission_checker.requires_forced_confirmation(&tool_name, &args);
489                    let hook_allows = matches!(
490                        hook_permission_override,
491                        Some(crate::HookPermissionOverride::Allow)
492                    );
493                    if ctx.auto_approve_permissions
494                        || ((ctx.bypass_permissions || hook_allows) && !force_ask)
495                    {
496                        continue;
497                    }
498                    let decision = if force_ask {
499                        permission_checker.check_or_request_forced(context).await
500                    } else if let Some(session_id) = ctx.session_id {
501                        permission_checker
502                            .check_or_request_for_session(session_id, context)
503                            .await
504                    } else {
505                        permission_checker.check_or_request(context).await
506                    };
507                    match decision {
508                        Ok(true) => continue,
509                        Ok(false) => {
510                            return Err(ToolError::Execution(format!(
511                                "Permission denied for: {}",
512                                resource
513                            )));
514                        }
515                        Err(PermissionError::ConfirmationRequired { .. }) => {
516                            crate::permission::PermissionRequest {
517                                request_id: call.id.clone(),
518                                request_generation:
519                                    crate::permission::PermissionRequest::fresh_generation(),
520                                session_id: ctx.session_id.unwrap_or_default().to_string(),
521                                workspace_path: None,
522                                tool_name: tool_name.clone(),
523                                permission_type,
524                                resource: resource.clone(),
525                                operation_summary: operation_summary.clone(),
526                                risk_level,
527                                reason_code: if force_ask {
528                                    crate::permission::PermissionReasonCode::ConfiguredAlwaysAsk
529                                } else {
530                                    crate::permission::PermissionReasonCode::RiskThreshold
531                                },
532                                effective_mode: bamboo_config::settings::PermissionMode::Default,
533                                bypass_requested: ctx.bypass_permissions,
534                                auto_approve_requested: ctx.auto_approve_permissions,
535                                policy_revision: 0,
536                                matched_rule: None,
537                                allowed_decisions:
538                                    crate::permission::PermissionRequest::forced_decisions(),
539                                suggested_matchers: crate::permission::conservative_matchers(
540                                    permission_type,
541                                    &resource,
542                                ),
543                            }
544                        }
545                        Err(other) => return Err(permission_error_to_tool_error(other)),
546                    }
547                };
548
549                // A worker/external relay gets the same typed request but only
550                // one-shot decisions are advertised until its protocol supports
551                // a stronger scope. No boolean downgrade can create a grant.
552                if let Some(proxy) = proxy {
553                    let approved = proxy
554                        .request_approval(crate::approval::ApprovalAsk {
555                            tool_name: tool_name.clone(),
556                            permission: permission_type.description().to_string(),
557                            resource: resource.clone(),
558                            permission_request: Some(request.clone()),
559                        })
560                        .await;
561                    if approved {
562                        continue;
563                    }
564                    return Err(ToolError::Execution(format!(
565                        "Permission denied by host for: {}",
566                        resource
567                    )));
568                }
569
570                // Interactive sessions pause through the legacy question shape
571                // while carrying the complete typed request alongside it.
572                if let Some(tx) = ctx.event_tx {
573                    let _ = tx
574                        .send(bamboo_agent_core::AgentEvent::ToolApprovalRequested {
575                            tool_call_id: call.id.clone(),
576                            tool_name: tool_name.clone(),
577                            parameters: args.clone(),
578                        })
579                        .await;
580
581                    let question = format!(
582                        "**Permission required**\n\nThe `{}` tool needs approval to {} on:\n\n`{}`",
583                        tool_name,
584                        permission_type.description(),
585                        resource
586                    );
587                    if let Some(config) = config {
588                        config.register_pending_request(request.clone());
589                    }
590                    let payload = serde_json::json!({
591                        "status": "awaiting_permission_approval",
592                        "question": question,
593                        "permission_type": permission_type,
594                        "resource": resource,
595                        "options": ["Approve", "Deny"],
596                        "allow_custom": false,
597                        "permission_request": request,
598                    });
599                    return Ok(Some(ToolOutcome::Completed(ToolResult {
600                        success: true,
601                        result: payload.to_string(),
602                        display_preference: Some("request_permissions".to_string()),
603                        images: Vec::new(),
604                    })));
605                }
606
607                return Err(ToolError::Execution(format!(
608                    "Permission approval required for: {}",
609                    resource
610                )));
611            }
612        }
613
614        Ok(None)
615    }
616
617    fn list_tools(&self) -> Vec<ToolSchema> {
618        self.registry.list_tools()
619    }
620
621    fn tool_mutability(&self, tool_name: &str) -> crate::ToolMutability {
622        self.registry
623            .get(tool_name)
624            .map(|tool| tool.classify(&serde_json::Value::Null).mutability)
625            .unwrap_or_else(|| crate::classify_tool(tool_name))
626    }
627
628    fn call_mutability(&self, call: &ToolCall) -> crate::ToolMutability {
629        let canonical = resolve_registered_tool_name(&self.registry, call.function.name.trim());
630        let args = bamboo_agent_core::parse_tool_args_best_effort(&call.function.arguments).0;
631        self.registry
632            .get(&canonical)
633            .map(|tool| tool.classify(&args).mutability)
634            .unwrap_or_else(|| self.tool_mutability(&canonical))
635    }
636
637    fn tool_concurrency_safe(&self, tool_name: &str) -> bool {
638        let canonical = resolve_registered_tool_name(&self.registry, tool_name);
639        self.registry
640            .get(&canonical)
641            .map(|tool| tool.classify(&serde_json::Value::Null).parallel_safe)
642            .unwrap_or_else(|| self.tool_mutability(&canonical) == crate::ToolMutability::ReadOnly)
643    }
644
645    fn call_concurrency_safe(&self, call: &ToolCall) -> bool {
646        let canonical = resolve_registered_tool_name(&self.registry, call.function.name.trim());
647        let args = bamboo_agent_core::parse_tool_args_best_effort(&call.function.arguments).0;
648        self.registry
649            .get(&canonical)
650            .map(|tool| tool.classify(&args).parallel_safe)
651            .unwrap_or_else(|| self.tool_concurrency_safe(&canonical))
652    }
653
654    fn call_parallel_classification(&self, call: &ToolCall) -> (crate::ToolMutability, bool) {
655        // One args-aware `classify` returns the (mutability, parallel_safe) pair
656        // with a single arg parse — the collapse of the former
657        // `call_mutability`/`call_concurrency_safe` pair.
658        let canonical = resolve_registered_tool_name(&self.registry, call.function.name.trim());
659        let args = bamboo_agent_core::parse_tool_args_best_effort(&call.function.arguments).0;
660        match self.registry.get(&canonical) {
661            Some(tool) => {
662                let class = tool.classify(&args);
663                (class.mutability, class.parallel_safe)
664            }
665            None => (
666                self.tool_mutability(&canonical),
667                self.tool_concurrency_safe(&canonical),
668            ),
669        }
670    }
671}
672
673/// Builder for constructing a BuiltinToolExecutor with custom tool configurations
674pub struct BuiltinToolExecutorBuilder {
675    registry: ToolRegistry,
676    permission_checker: Option<Arc<dyn PermissionChecker>>,
677}
678
679impl BuiltinToolExecutorBuilder {
680    /// Creates a new builder with no tools registered
681    pub fn new() -> Self {
682        Self {
683            registry: ToolRegistry::new(),
684            permission_checker: None,
685        }
686    }
687
688    /// Registers all default built-in tools
689    pub fn with_default_tools(self) -> Self {
690        BuiltinToolExecutor::register_builtin_tools(&self.registry, None);
691        self
692    }
693
694    /// Registers a specific filesystem tool by name
695    pub fn with_filesystem_tool(self, name: &str) -> Result<Self, ToolError> {
696        match name {
697            "Read" => self.registry.register(ReadTool::new()),
698            "Write" => self.registry.register(WriteTool::new()),
699            // apply_patch is now an alias for Edit
700            "Edit" | "apply_patch" => self.registry.register(EditTool::new()),
701            "NotebookEdit" => self.registry.register(NotebookEditTool::new()),
702            _ => return Err(ToolError::NotFound(format!("Unknown tool: {}", name))),
703        }
704        .map_err(|e| ToolError::Execution(e.to_string()))?;
705        Ok(self)
706    }
707
708    /// Registers a specific command tool by name
709    pub fn with_command_tool(self, name: &str) -> Result<Self, ToolError> {
710        match name {
711            "Bash" => self.registry.register(BashTool::new()),
712            "BashOutput" => self.registry.register(BashOutputTool::new()),
713            "KillShell" => self.registry.register(KillShellTool::new()),
714            "Task" => self.registry.register(TaskTool::new()),
715            _ => return Err(ToolError::NotFound(format!("Unknown tool: {}", name))),
716        }
717        .map_err(|e| ToolError::Execution(e.to_string()))?;
718        Ok(self)
719    }
720
721    /// Registers a custom tool
722    pub fn with_tool<T: Tool + 'static>(self, tool: T) -> Result<Self, ToolError> {
723        self.registry
724            .register(tool)
725            .map_err(|e| ToolError::Execution(e.to_string()))?;
726        Ok(self)
727    }
728
729    /// Sets a permission checker for this executor
730    pub fn with_permission_checker(mut self, checker: Arc<dyn PermissionChecker>) -> Self {
731        self.permission_checker = Some(checker);
732        self
733    }
734
735    /// Builds the executor
736    pub fn build(self) -> BuiltinToolExecutor {
737        BuiltinToolExecutor {
738            registry: self.registry,
739            permission_checker: self.permission_checker,
740        }
741    }
742}
743
744impl Default for BuiltinToolExecutorBuilder {
745    fn default() -> Self {
746        Self::new()
747    }
748}
749
750#[cfg(test)]
751mod tests {
752    use super::*;
753    use bamboo_agent_core::AgentEvent;
754    use bamboo_agent_core::FunctionCall;
755    use bamboo_agent_core::ToolCtx;
756    use bamboo_agent_core::ToolExecutionContext;
757    use bamboo_domain::tool_names::{normalize_tool_ref, BUILTIN_TOOL_NAMES};
758    use serde_json::json;
759    use std::sync::atomic::{AtomicUsize, Ordering};
760    use std::sync::Arc;
761    use tokio::fs;
762    use tokio::sync::mpsc;
763
764    use crate::tools::WriteTool;
765
766    fn make_tool_call(name: &str, args: serde_json::Value) -> ToolCall {
767        ToolCall {
768            id: "call_1".to_string(),
769            tool_type: "function".to_string(),
770            function: FunctionCall {
771                name: name.to_string(),
772                arguments: args.to_string(),
773            },
774        }
775    }
776
777    fn make_tool_call_with_raw_args(name: &str, raw_args: &str) -> ToolCall {
778        ToolCall {
779            id: "call_1".to_string(),
780            tool_type: "function".to_string(),
781            function: FunctionCall {
782                name: name.to_string(),
783                arguments: raw_args.to_string(),
784            },
785        }
786    }
787
788    fn make_executor(
789        permission_checker: Option<Arc<dyn PermissionChecker>>,
790    ) -> BuiltinToolExecutor {
791        let builder = BuiltinToolExecutorBuilder::new()
792            .with_tool(WriteTool::new())
793            .expect("register Write tool");
794
795        let builder = match permission_checker {
796            Some(checker) => builder.with_permission_checker(checker),
797            None => builder,
798        };
799
800        builder.build()
801    }
802
803    async fn permission_request_payload(
804        executor: &BuiltinToolExecutor,
805        session_id: &str,
806        args: serde_json::Value,
807    ) -> serde_json::Value {
808        let (event_tx, _event_rx) = mpsc::channel(4);
809        let call = make_tool_call("Write", args);
810        let ctx = ToolExecutionContext {
811            session_id: Some(session_id),
812            tool_call_id: &call.id,
813            event_tx: Some(&event_tx),
814            available_tool_schemas: None,
815            bypass_permissions: false,
816            auto_approve_permissions: false,
817            plan_read_only: false,
818            can_async_resume: false,
819            bash_completion_sink: None,
820            pre_parsed_args: None,
821        };
822        let result = executor
823            .execute_with_context(&call, ctx)
824            .await
825            .expect("interactive permission gate should pause");
826        serde_json::from_str(&result.result).expect("typed permission payload")
827    }
828
829    struct RecordingApprovalProxy {
830        requests: Arc<AtomicUsize>,
831        approve: bool,
832    }
833
834    #[async_trait]
835    impl crate::approval::ApprovalProxy for RecordingApprovalProxy {
836        async fn request_approval(&self, _ask: crate::approval::ApprovalAsk) -> bool {
837            self.requests.fetch_add(1, Ordering::SeqCst);
838            self.approve
839        }
840    }
841
842    #[test]
843    fn test_normalize_tool_ref_accepts_claude_style_names() {
844        assert_eq!(
845            normalize_tool_ref("default::Bash"),
846            Some("Bash".to_string())
847        );
848    }
849
850    #[test]
851    fn test_normalize_tool_ref_accepts_legacy_camel_aliases() {
852        assert_eq!(
853            normalize_tool_ref("default::fileExists"),
854            Some("FileExists".to_string())
855        );
856        assert_eq!(
857            normalize_tool_ref("default::getCurrentDir"),
858            Some("GetCurrentDir".to_string())
859        );
860        assert_eq!(
861            normalize_tool_ref("default::getFileInfo"),
862            Some("GetFileInfo".to_string())
863        );
864        assert_eq!(
865            normalize_tool_ref("default::setWorkspace"),
866            Some("SetWorkspace".to_string())
867        );
868        assert_eq!(
869            normalize_tool_ref("default::sleep"),
870            Some("Sleep".to_string())
871        );
872    }
873
874    #[test]
875    fn test_normalize_tool_ref_accepts_legacy_snake_case_aliases() {
876        assert_eq!(
877            normalize_tool_ref("default::execute_command"),
878            Some("Bash".to_string())
879        );
880        assert_eq!(
881            normalize_tool_ref("default::file_exists"),
882            Some("FileExists".to_string())
883        );
884        assert_eq!(
885            normalize_tool_ref("default::get_current_dir"),
886            Some("GetCurrentDir".to_string())
887        );
888        assert_eq!(
889            normalize_tool_ref("default::get_file_info"),
890            Some("GetFileInfo".to_string())
891        );
892        assert_eq!(
893            normalize_tool_ref("default::list_directory"),
894            Some("Glob".to_string())
895        );
896        assert_eq!(
897            normalize_tool_ref("default::memory_note"),
898            Some("memory_note".to_string())
899        );
900        assert_eq!(
901            normalize_tool_ref("default::read_file"),
902            Some("Read".to_string())
903        );
904        assert_eq!(
905            normalize_tool_ref("default::set_workspace"),
906            Some("SetWorkspace".to_string())
907        );
908        assert_eq!(
909            normalize_tool_ref("default::write_file"),
910            Some("Write".to_string())
911        );
912    }
913
914    #[test]
915    fn test_normalize_tool_ref_accepts_spawn_task_aliases() {
916        for alias in [
917            "default::spawn_session",
918            "default::sub_session",
919            "default::sub_task",
920            "default::team_agent",
921            "default::child_session",
922        ] {
923            assert_eq!(normalize_tool_ref(alias), Some("SubAgent".to_string()));
924        }
925    }
926
927    #[test]
928    fn test_normalize_tool_ref_accepts_server_overlay_tools() {
929        assert_eq!(normalize_tool_ref("compress_context"), None);
930        assert_eq!(
931            normalize_tool_ref("default::read_skill_resource"),
932            Some("read_skill_resource".to_string())
933        );
934    }
935
936    #[tokio::test]
937    async fn test_executor_accepts_legacy_read_file_path_argument() {
938        let dir = tempfile::tempdir().unwrap();
939        let file_path = dir.path().join("legacy-read.txt");
940        fs::write(&file_path, "legacy read content").await.unwrap();
941
942        let executor = BuiltinToolExecutor::new();
943        let call = make_tool_call("read_file", json!({"path": file_path}));
944
945        let result = executor.execute(&call).await.unwrap();
946        assert!(result.success);
947        assert!(result.result.contains("legacy read content"));
948    }
949
950    #[tokio::test]
951    async fn test_executor_accepts_legacy_list_directory_without_pattern() {
952        let dir = tempfile::tempdir().unwrap();
953        let file_path = dir.path().join("legacy-list.txt");
954        fs::write(&file_path, "legacy list content").await.unwrap();
955
956        let executor = BuiltinToolExecutor::new();
957        let call = make_tool_call("list_directory", json!({"path": dir.path()}));
958
959        let result = executor.execute(&call).await.unwrap();
960        assert!(result.success);
961        assert!(result.result.contains("legacy-list.txt"));
962    }
963
964    #[tokio::test]
965    async fn test_executor_accepts_canonical_read_with_path_argument() {
966        let dir = tempfile::tempdir().unwrap();
967        let file_path = dir.path().join("canonical-read.txt");
968        fs::write(&file_path, "canonical read content")
969            .await
970            .unwrap();
971
972        let executor = BuiltinToolExecutor::new();
973        let call = make_tool_call("Read", json!({"path": file_path}));
974
975        let result = executor.execute(&call).await.unwrap();
976        assert!(result.success);
977        assert!(result.result.contains("canonical read content"));
978    }
979
980    #[tokio::test]
981    async fn test_executor_accepts_canonical_glob_without_pattern_when_path_present() {
982        let dir = tempfile::tempdir().unwrap();
983        let file_path = dir.path().join("canonical-list.txt");
984        fs::write(&file_path, "canonical list content")
985            .await
986            .unwrap();
987
988        let executor = BuiltinToolExecutor::new();
989        let call = make_tool_call("Glob", json!({"path": dir.path()}));
990
991        let result = executor.execute(&call).await.unwrap();
992        assert!(result.success);
993        assert!(result.result.contains("canonical-list.txt"));
994    }
995
996    #[test]
997    fn test_executor_workspace_mutability_depends_on_path_argument() {
998        let executor = BuiltinToolExecutor::new();
999        let get_call = make_tool_call("Workspace", json!({}));
1000        let set_call = make_tool_call("Workspace", json!({"path": "/tmp"}));
1001
1002        assert_eq!(
1003            executor.call_mutability(&get_call),
1004            crate::ToolMutability::ReadOnly
1005        );
1006        assert!(executor.call_concurrency_safe(&get_call));
1007
1008        assert_eq!(
1009            executor.call_mutability(&set_call),
1010            crate::ToolMutability::Mutating
1011        );
1012        assert!(!executor.call_concurrency_safe(&set_call));
1013    }
1014
1015    #[test]
1016    fn call_parallel_classification_matches_individual_methods() {
1017        // Regression guard for the issue #17 perf refactor: the combined
1018        // `call_parallel_classification` (which parses args once) must return the
1019        // exact same (mutability, concurrency_safe) pair as calling
1020        // `call_mutability` and `call_concurrency_safe` separately (which each
1021        // parse args). Covers a read-only tool, mutating tools, and an
1022        // args-aware tool (Workspace get vs set) so every branch of the
1023        // single-parse override is exercised.
1024        let executor = BuiltinToolExecutor::new();
1025        let cases: &[(&str, serde_json::Value)] = &[
1026            ("Read", json!({})),
1027            ("Grep", json!({"pattern": "x"})),
1028            (
1029                "Write",
1030                json!({"file_path": "/tmp/par_cls.txt", "content": "y"}),
1031            ),
1032            ("Bash", json!({"command": "echo hi"})),
1033            ("Workspace", json!({})),
1034            ("Workspace", json!({"path": "/tmp"})),
1035        ];
1036
1037        for (name, args) in cases {
1038            let call = make_tool_call(name, args.clone());
1039            let expected_mutability = executor.call_mutability(&call);
1040            let expected_concurrency = executor.call_concurrency_safe(&call);
1041            let (mutability, concurrency) = executor.call_parallel_classification(&call);
1042            assert_eq!(
1043                mutability, expected_mutability,
1044                "mutability mismatch for {name} ({args})"
1045            );
1046            assert_eq!(
1047                concurrency, expected_concurrency,
1048                "concurrency mismatch for {name} ({args})"
1049            );
1050        }
1051    }
1052
1053    #[test]
1054    fn list_tools_snapshot_is_stable_across_calls() {
1055        // The per-round schema cache (issue #17 Part A) assumes the executor's
1056        // `list_tools()` is stable within a round: a snapshot taken once must
1057        // equal a fresh call. Guards that invariant so caching the set for the
1058        // duration of a round can't serve a stale or filtered view.
1059        let executor = BuiltinToolExecutor::new();
1060        let first: Vec<String> = executor
1061            .list_tools()
1062            .into_iter()
1063            .map(|s| s.function.name)
1064            .collect();
1065        let second: Vec<String> = executor
1066            .list_tools()
1067            .into_iter()
1068            .map(|s| s.function.name)
1069            .collect();
1070        assert!(!first.is_empty(), "builtin executor should expose tools");
1071        assert_eq!(
1072            first, second,
1073            "list_tools() must be deterministic per round"
1074        );
1075    }
1076
1077    #[tokio::test]
1078    async fn test_executor_recovers_truncated_json_arguments() {
1079        let dir = tempfile::tempdir().unwrap();
1080        let path = dir.path().join("recovered-write.txt");
1081
1082        // Missing closing brace simulates EOF while parsing an object.
1083        let malformed_args = format!(
1084            r#"{{"file_path":"{}","content":"recovered content""#,
1085            path.display()
1086        );
1087
1088        let executor = BuiltinToolExecutor::new();
1089        let call = make_tool_call_with_raw_args("Write", &malformed_args);
1090
1091        let result = executor
1092            .execute(&call)
1093            .await
1094            .expect("truncated JSON should be auto-repaired");
1095        assert!(result.success);
1096
1097        let written = fs::read_to_string(&path)
1098            .await
1099            .expect("file should be written");
1100        assert_eq!(written, "recovered content");
1101    }
1102
1103    #[test]
1104    fn test_normalize_tool_ref_rejects_unknown_tool() {
1105        assert_eq!(normalize_tool_ref("default::search"), None);
1106    }
1107
1108    #[test]
1109    fn test_executor_does_not_expose_legacy_tools() {
1110        let executor = BuiltinToolExecutor::new();
1111        let tool_names: Vec<String> = executor
1112            .list_tools()
1113            .into_iter()
1114            .map(|schema| schema.function.name)
1115            .collect();
1116
1117        for legacy in ["claude_code", "search_in_file", "search_in_project"] {
1118            assert!(!tool_names.iter().any(|name| name == legacy));
1119        }
1120    }
1121
1122    #[test]
1123    fn test_critical_tool_schemas_match_claude_shapes() {
1124        let executor = BuiltinToolExecutor::new();
1125        let tools = executor.list_tools();
1126
1127        let get_params = |name: &str| {
1128            tools
1129                .iter()
1130                .find(|tool| tool.function.name == name)
1131                .unwrap()
1132                .function
1133                .parameters
1134                .clone()
1135        };
1136
1137        let grep = get_params("Grep");
1138        assert_eq!(grep["required"], json!(["pattern"]));
1139        assert_eq!(
1140            grep["properties"]["output_mode"]["enum"],
1141            json!(["content", "files_with_matches", "count"])
1142        );
1143        assert!(grep["properties"]["-A"].is_object());
1144        assert!(grep["properties"]["-B"].is_object());
1145        assert!(grep["properties"]["-C"].is_object());
1146        assert!(grep["properties"]["-n"].is_object());
1147        assert!(grep["properties"]["-i"].is_object());
1148
1149        let edit = get_params("Edit");
1150        assert_eq!(edit["required"], json!(["file_path"]));
1151        assert_eq!(edit["properties"]["old_string"]["type"], "string");
1152        assert_eq!(edit["properties"]["new_string"]["type"], "string");
1153        assert_eq!(edit["properties"]["patch"]["type"], "string");
1154        assert_eq!(edit["properties"]["replace_all"]["type"], "boolean");
1155        assert!(edit.get("oneOf").is_none());
1156
1157        // apply_patch is now an alias for Edit – its schema is the Edit
1158        // schema, so we just verify that Edit includes the patch property.
1159        assert_eq!(edit["properties"]["patch"]["type"], "string");
1160        assert_eq!(edit["properties"]["line_number"]["type"], "integer");
1161
1162        let bash = get_params("Bash");
1163        assert_eq!(bash["required"], json!(["command"]));
1164        assert_eq!(bash["properties"]["run_in_background"]["type"], "boolean");
1165        assert_eq!(bash["properties"]["workdir"]["type"], "string");
1166
1167        let bash_output = get_params("BashOutput");
1168        assert_eq!(bash_output["required"], json!(["bash_id"]));
1169        assert_eq!(bash_output["properties"]["filter"]["type"], "string");
1170    }
1171
1172    #[test]
1173    fn test_tool_schemas_avoid_openai_forbidden_top_level_keywords() {
1174        let executor = BuiltinToolExecutor::new();
1175        let tools = executor.list_tools();
1176        let forbidden = ["oneOf", "anyOf", "allOf", "not", "enum"];
1177
1178        for tool in tools {
1179            let params = &tool.function.parameters;
1180            assert_eq!(
1181                params["type"], "object",
1182                "tool '{}' parameters must be a top-level object schema",
1183                tool.function.name
1184            );
1185            for key in forbidden {
1186                assert!(
1187                    params.get(key).is_none(),
1188                    "tool '{}' parameters contains forbidden top-level keyword '{}'",
1189                    tool.function.name,
1190                    key
1191                );
1192            }
1193        }
1194    }
1195
1196    #[test]
1197    fn test_executor_has_all_builtin_tools() {
1198        let executor = BuiltinToolExecutor::new();
1199        let tools = executor.list_tools();
1200
1201        assert_eq!(tools.len(), BUILTIN_TOOL_NAMES.len());
1202
1203        let tool_names: Vec<String> = tools.iter().map(|t| t.function.name.clone()).collect();
1204        for tool_name in BUILTIN_TOOL_NAMES {
1205            assert!(tool_names.contains(&tool_name.to_string()));
1206        }
1207    }
1208
1209    #[test]
1210    fn test_executor_builds_enhanced_prompt() {
1211        let executor = BuiltinToolExecutor::new();
1212        let prompt = executor.build_enhanced_prompt(GuideBuildContext::default());
1213        assert!(prompt.contains("## Tool Usage Guidelines"));
1214        assert!(prompt.contains("**Read**"));
1215    }
1216
1217    #[test]
1218    fn test_executor_builder_empty() {
1219        let executor = BuiltinToolExecutorBuilder::new().build();
1220        assert!(executor.list_tools().is_empty());
1221    }
1222
1223    #[test]
1224    fn test_executor_builder_with_default_tools() {
1225        let executor = BuiltinToolExecutorBuilder::new()
1226            .with_default_tools()
1227            .build();
1228        assert_eq!(executor.list_tools().len(), BUILTIN_TOOL_NAMES.len());
1229    }
1230
1231    #[test]
1232    fn test_executor_builder_with_specific_tool() {
1233        let executor = BuiltinToolExecutorBuilder::new()
1234            .with_filesystem_tool("Read")
1235            .unwrap()
1236            .build();
1237
1238        let tools = executor.list_tools();
1239        assert_eq!(tools.len(), 1);
1240        assert_eq!(tools[0].function.name, "Read");
1241    }
1242
1243    #[tokio::test]
1244    async fn test_executor_skips_permission_checks_without_checker() {
1245        let executor = make_executor(None);
1246        let path = "/tmp/executor_permission_none.txt";
1247        let _ = fs::remove_file(path).await;
1248
1249        let call = make_tool_call("Write", json!({"file_path": path, "content": "ok"}));
1250        let result = executor.execute(&call).await.expect("execute tool");
1251
1252        assert!(result.success);
1253        let _ = fs::remove_file(path).await;
1254    }
1255
1256    #[tokio::test]
1257    async fn test_executor_with_permission_checker_enforces_checks() {
1258        let checker = Arc::new(crate::permission::DenyDangerousPermissionChecker);
1259        let executor = make_executor(Some(checker));
1260        let path = "/tmp/executor_permission_denied.txt";
1261        let _ = fs::remove_file(path).await;
1262
1263        let call = make_tool_call("Write", json!({"file_path": path, "content": "nope"}));
1264        let result = executor.execute(&call).await;
1265
1266        assert!(matches!(result, Err(ToolError::Execution(_))));
1267        assert!(fs::metadata(path).await.is_err());
1268    }
1269
1270    #[tokio::test]
1271    async fn test_bypass_permissions_skips_checker() {
1272        // Model the worker side of a child whose parent bypass flag was inherited:
1273        // a production Bash tool under the production config evaluator must
1274        // execute an ordinary command directly. Even though both a parent
1275        // approval proxy and a human-event sink are installed, neither path may
1276        // be touched.
1277        let config = Arc::new(crate::permission::PermissionConfig::new());
1278        let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
1279        let executor = BuiltinToolExecutorBuilder::new()
1280            .with_tool(BashTool::new())
1281            .expect("register Bash tool")
1282            .with_permission_checker(checker)
1283            .build();
1284        let dir = tempfile::tempdir().unwrap();
1285        let path = dir.path().join("bypass_allows_bash.txt");
1286        let command = format!("printf ordinary > {}", path.display());
1287        let approval_requests = Arc::new(AtomicUsize::new(0));
1288        let proxy: Arc<dyn crate::approval::ApprovalProxy> = Arc::new(RecordingApprovalProxy {
1289            requests: approval_requests.clone(),
1290            approve: true,
1291        });
1292        let (event_tx, mut event_rx) = mpsc::channel(8);
1293
1294        let call = make_tool_call("Bash", json!({"command": command}));
1295        let ctx = ToolExecutionContext {
1296            session_id: Some("s-bypass"),
1297            tool_call_id: &call.id,
1298            event_tx: Some(&event_tx),
1299            available_tool_schemas: None,
1300            bypass_permissions: true,
1301            auto_approve_permissions: false,
1302            plan_read_only: false,
1303            can_async_resume: false,
1304            bash_completion_sink: None,
1305            pre_parsed_args: None,
1306        };
1307        let result = crate::approval::with_approval_proxy(
1308            Some(proxy),
1309            executor.execute_with_context(&call, ctx),
1310        )
1311        .await;
1312
1313        assert!(result.is_ok(), "bypass should allow the write: {result:?}");
1314        assert_eq!(fs::read_to_string(&path).await.unwrap(), "ordinary");
1315        assert_eq!(
1316            approval_requests.load(Ordering::SeqCst),
1317            0,
1318            "ordinary bypassed child command must not invoke the parent reviewer"
1319        );
1320        assert!(
1321            event_rx.try_recv().is_err(),
1322            "ordinary bypassed child command must not emit a human approval event"
1323        );
1324    }
1325
1326    #[tokio::test]
1327    async fn hook_allow_skips_configured_ask_for_exact_call() {
1328        let dir = tempfile::tempdir().unwrap();
1329        let path = dir.path().join("hook-allowed.txt");
1330        let path_str = path.to_str().unwrap().to_string();
1331        let config = Arc::new(crate::permission::PermissionConfig::new());
1332        config.set_ask_rules([format!("Write({}/**)", dir.path().to_str().unwrap())]);
1333        let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
1334        let executor = make_executor(Some(checker));
1335        let call = make_tool_call(
1336            "Write",
1337            json!({"file_path": path_str, "content": "allowed by hook"}),
1338        );
1339        let ctx = ToolExecutionContext {
1340            session_id: Some("s-hook-allow"),
1341            tool_call_id: &call.id,
1342            event_tx: None,
1343            available_tool_schemas: None,
1344            bypass_permissions: false,
1345            auto_approve_permissions: false,
1346            plan_read_only: false,
1347            can_async_resume: false,
1348            bash_completion_sink: None,
1349            pre_parsed_args: None,
1350        };
1351
1352        let result = crate::with_hook_permission_override(
1353            Some(crate::HookPermissionOverride::Allow),
1354            &call.id,
1355            executor.execute_with_context(&call, ctx),
1356        )
1357        .await;
1358
1359        assert!(
1360            result.is_ok(),
1361            "hook allow should skip ordinary ask: {result:?}"
1362        );
1363        assert_eq!(fs::read_to_string(path).await.unwrap(), "allowed by hook");
1364        assert_eq!(
1365            crate::current_hook_permission_override(&call.id),
1366            None,
1367            "the one-call override must not leak"
1368        );
1369    }
1370
1371    #[tokio::test]
1372    async fn hook_allow_cannot_skip_hard_dangerous_parent_review() {
1373        let config = Arc::new(crate::permission::PermissionConfig::new());
1374        let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
1375        let executor = BuiltinToolExecutorBuilder::new()
1376            .with_tool(BashTool::new())
1377            .expect("register Bash tool")
1378            .with_permission_checker(checker)
1379            .build();
1380        let dir = tempfile::tempdir().unwrap();
1381        let path = dir.path().join("hard-dangerous-must-not-run.txt");
1382        let command = format!("eval 'printf denied > {}'", path.display());
1383        let requests = Arc::new(AtomicUsize::new(0));
1384        let proxy: Arc<dyn crate::approval::ApprovalProxy> = Arc::new(RecordingApprovalProxy {
1385            requests: requests.clone(),
1386            approve: false,
1387        });
1388        let call = make_tool_call("Bash", json!({"command": command}));
1389        let ctx = ToolExecutionContext {
1390            session_id: Some("s-hook-hard-dangerous"),
1391            tool_call_id: &call.id,
1392            event_tx: None,
1393            available_tool_schemas: None,
1394            bypass_permissions: true,
1395            auto_approve_permissions: false,
1396            plan_read_only: false,
1397            can_async_resume: false,
1398            bash_completion_sink: None,
1399            pre_parsed_args: None,
1400        };
1401
1402        let result = crate::with_hook_permission_override(
1403            Some(crate::HookPermissionOverride::Allow),
1404            &call.id,
1405            crate::approval::with_approval_proxy(
1406                Some(proxy),
1407                executor.execute_with_context(&call, ctx),
1408            ),
1409        )
1410        .await;
1411
1412        assert!(
1413            matches!(result, Err(ToolError::Execution(ref message)) if message.contains("denied by host")),
1414            "hard-dangerous review must remain authoritative: {result:?}"
1415        );
1416        assert_eq!(requests.load(Ordering::SeqCst), 1);
1417        assert!(!path.exists());
1418    }
1419
1420    #[tokio::test]
1421    async fn hook_allow_cannot_skip_explicit_deny() {
1422        let dir = tempfile::tempdir().unwrap();
1423        let path = dir.path().join("explicit-deny.txt");
1424        let path_str = path.to_str().unwrap().to_string();
1425        let config = Arc::new(crate::permission::PermissionConfig::new());
1426        config.deny_scoped_session_permission(
1427            "s-hook-explicit-deny",
1428            crate::permission::PermissionType::WriteFile,
1429            path_str.clone(),
1430        );
1431        let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
1432        let executor = make_executor(Some(checker));
1433        let call = make_tool_call(
1434            "Write",
1435            json!({"file_path": path_str, "content": "must not be written"}),
1436        );
1437        let ctx = ToolExecutionContext {
1438            session_id: Some("s-hook-explicit-deny"),
1439            tool_call_id: &call.id,
1440            event_tx: None,
1441            available_tool_schemas: None,
1442            bypass_permissions: false,
1443            auto_approve_permissions: false,
1444            plan_read_only: false,
1445            can_async_resume: false,
1446            bash_completion_sink: None,
1447            pre_parsed_args: None,
1448        };
1449
1450        let result = crate::with_hook_permission_override(
1451            Some(crate::HookPermissionOverride::Allow),
1452            &call.id,
1453            executor.execute_with_context(&call, ctx),
1454        )
1455        .await;
1456
1457        assert!(
1458            matches!(result, Err(ToolError::Execution(ref message)) if message.contains("remembered session decision")),
1459            "explicit deny must remain authoritative: {result:?}"
1460        );
1461        assert!(!path.exists());
1462    }
1463
1464    #[tokio::test]
1465    async fn test_forced_ask_rule_overrides_bypass() {
1466        // A hard-dangerous Bash command must still traverse the worker's parent
1467        // approval proxy under bypass. The returned verdict is authoritative:
1468        // deny prevents execution, while approve lets the exact command run.
1469        let config = Arc::new(crate::permission::PermissionConfig::new());
1470        let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
1471        let executor = BuiltinToolExecutorBuilder::new()
1472            .with_tool(BashTool::new())
1473            .expect("register Bash tool")
1474            .with_permission_checker(checker)
1475            .build();
1476        let dir = tempfile::tempdir().unwrap();
1477        let denied_path = dir.path().join("forced-denied.txt");
1478        let denied_command = format!("eval 'printf denied > {}'", denied_path.display());
1479        let denied_requests = Arc::new(AtomicUsize::new(0));
1480        let deny_proxy: Arc<dyn crate::approval::ApprovalProxy> =
1481            Arc::new(RecordingApprovalProxy {
1482                requests: denied_requests.clone(),
1483                approve: false,
1484            });
1485
1486        let denied_call = make_tool_call("Bash", json!({"command": denied_command}));
1487        let denied_ctx = ToolExecutionContext {
1488            session_id: Some("s-forced"),
1489            tool_call_id: &denied_call.id,
1490            event_tx: None,
1491            available_tool_schemas: None,
1492            bypass_permissions: true,
1493            auto_approve_permissions: false,
1494            plan_read_only: false,
1495            can_async_resume: false,
1496            bash_completion_sink: None,
1497            pre_parsed_args: None,
1498        };
1499        let denied = crate::approval::with_approval_proxy(
1500            Some(deny_proxy),
1501            executor.execute_with_context(&denied_call, denied_ctx),
1502        )
1503        .await;
1504
1505        assert!(
1506            matches!(denied, Err(ToolError::Execution(ref message)) if message.contains("denied by host")),
1507            "parent denial must block forced-ask execution under bypass: {denied:?}"
1508        );
1509        assert_eq!(denied_requests.load(Ordering::SeqCst), 1);
1510        assert!(!denied_path.exists(), "denied command must not execute");
1511
1512        let approved_path = dir.path().join("forced-approved.txt");
1513        let approved_command = format!("eval 'printf approved > {}'", approved_path.display());
1514        let approved_requests = Arc::new(AtomicUsize::new(0));
1515        let approve_proxy: Arc<dyn crate::approval::ApprovalProxy> =
1516            Arc::new(RecordingApprovalProxy {
1517                requests: approved_requests.clone(),
1518                approve: true,
1519            });
1520        let approved_call = make_tool_call("Bash", json!({"command": approved_command}));
1521        let approved_ctx = ToolExecutionContext {
1522            session_id: Some("s-forced"),
1523            tool_call_id: &approved_call.id,
1524            event_tx: None,
1525            available_tool_schemas: None,
1526            bypass_permissions: true,
1527            auto_approve_permissions: false,
1528            plan_read_only: false,
1529            can_async_resume: false,
1530            bash_completion_sink: None,
1531            pre_parsed_args: None,
1532        };
1533        let approved = crate::approval::with_approval_proxy(
1534            Some(approve_proxy),
1535            executor.execute_with_context(&approved_call, approved_ctx),
1536        )
1537        .await;
1538
1539        assert!(
1540            approved.is_ok(),
1541            "parent approval must allow forced-ask execution under bypass: {approved:?}"
1542        );
1543        assert_eq!(approved_requests.load(Ordering::SeqCst), 1);
1544        assert_eq!(fs::read_to_string(approved_path).await.unwrap(), "approved");
1545    }
1546
1547    #[tokio::test]
1548    async fn auto_executes_forced_ask_without_proxy_or_human_event() {
1549        let config = Arc::new(crate::permission::PermissionConfig::new());
1550        let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
1551        let executor = BuiltinToolExecutorBuilder::new()
1552            .with_tool(BashTool::new())
1553            .expect("register Bash tool")
1554            .with_permission_checker(checker)
1555            .build();
1556        let dir = tempfile::tempdir().unwrap();
1557        let path = dir.path().join("auto-forced.txt");
1558        let command = format!("eval 'printf auto > {}'", path.display());
1559        let approval_requests = Arc::new(AtomicUsize::new(0));
1560        let proxy: Arc<dyn crate::approval::ApprovalProxy> = Arc::new(RecordingApprovalProxy {
1561            requests: approval_requests.clone(),
1562            approve: false,
1563        });
1564        let (event_tx, mut event_rx) = mpsc::channel(8);
1565        let call = make_tool_call("Bash", json!({"command": command}));
1566        let ctx = ToolExecutionContext {
1567            session_id: Some("s-auto"),
1568            tool_call_id: &call.id,
1569            event_tx: Some(&event_tx),
1570            available_tool_schemas: None,
1571            bypass_permissions: false,
1572            auto_approve_permissions: true,
1573            plan_read_only: false,
1574            can_async_resume: false,
1575            bash_completion_sink: None,
1576            pre_parsed_args: None,
1577        };
1578
1579        let result = crate::approval::with_approval_proxy(
1580            Some(proxy),
1581            executor.execute_with_context(&call, ctx),
1582        )
1583        .await;
1584
1585        assert!(result.is_ok(), "Auto should execute directly: {result:?}");
1586        assert_eq!(fs::read_to_string(path).await.unwrap(), "auto");
1587        assert_eq!(approval_requests.load(Ordering::SeqCst), 0);
1588        assert!(
1589            event_rx.try_recv().is_err(),
1590            "Auto must not emit an interactive approval request"
1591        );
1592    }
1593
1594    #[tokio::test]
1595    async fn auto_never_overrides_guardian_read_only_hard_deny() {
1596        let config = Arc::new(crate::permission::PermissionConfig::new());
1597        let base: Arc<dyn crate::permission::PermissionChecker> = Arc::new(
1598            crate::permission::ConfigPermissionChecker::new(config.clone()),
1599        );
1600        let checker = Arc::new(crate::permission::GuardianReadOnlyChecker::new(base));
1601        let executor = BuiltinToolExecutorBuilder::new()
1602            .with_tool(BashTool::new())
1603            .expect("register Bash tool")
1604            .with_permission_checker(checker)
1605            .build();
1606        let dir = tempfile::tempdir().unwrap();
1607        let path = dir.path().join("guardian-mutation.txt");
1608        let command = format!("printf blocked > {}", path.display());
1609        let call = make_tool_call("Bash", json!({"command": command}));
1610        let ctx = ToolExecutionContext {
1611            session_id: Some("guardian-auto"),
1612            tool_call_id: &call.id,
1613            event_tx: None,
1614            available_tool_schemas: None,
1615            bypass_permissions: false,
1616            auto_approve_permissions: true,
1617            plan_read_only: false,
1618            can_async_resume: false,
1619            bash_completion_sink: None,
1620            pre_parsed_args: None,
1621        };
1622
1623        let error = executor
1624            .execute_with_context(&call, ctx)
1625            .await
1626            .expect_err("Auto must retain Guardian read-only authority");
1627
1628        assert!(error.to_string().contains("Guardian reviewer is read-only"));
1629        assert!(!path.exists());
1630    }
1631
1632    #[tokio::test]
1633    async fn test_explicit_deny_overrides_bypass() {
1634        let dir = tempfile::tempdir().unwrap();
1635        let path = dir.path().join("explicit-deny.txt");
1636        let path_str = path.to_str().unwrap();
1637        let config = Arc::new(crate::permission::PermissionConfig::new());
1638        config.add_rule(crate::permission::PermissionRule::new(
1639            crate::permission::PermissionType::WriteFile,
1640            path_str,
1641            false,
1642        ));
1643        let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
1644        let executor = make_executor(Some(checker));
1645        let call = make_tool_call(
1646            "Write",
1647            json!({"file_path": path_str, "content": "blocked"}),
1648        );
1649        let ctx = ToolExecutionContext {
1650            session_id: Some("s-explicit-deny"),
1651            tool_call_id: &call.id,
1652            event_tx: None,
1653            available_tool_schemas: None,
1654            bypass_permissions: true,
1655            auto_approve_permissions: false,
1656            plan_read_only: false,
1657            can_async_resume: false,
1658            bash_completion_sink: None,
1659            pre_parsed_args: None,
1660        };
1661
1662        let result = executor.execute_with_context(&call, ctx).await;
1663        assert!(
1664            matches!(result, Err(ToolError::Execution(ref message)) if message.contains("explicit policy")),
1665            "explicit deny must beat bypass: {result:?}"
1666        );
1667        assert!(!path.exists());
1668    }
1669
1670    #[tokio::test]
1671    async fn test_explicit_delete_deny_overrides_bypass() {
1672        let config = Arc::new(crate::permission::PermissionConfig::new());
1673        config.add_rule(crate::permission::PermissionRule::new(
1674            crate::permission::PermissionType::DeleteOperation,
1675            "rm child-to-preserve",
1676            false,
1677        ));
1678        let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
1679        let executor = BuiltinToolExecutorBuilder::new()
1680            .with_tool(BashTool::new())
1681            .expect("register Bash tool")
1682            .with_permission_checker(checker)
1683            .build();
1684        let call = make_tool_call("Bash", json!({"command": "rm child-to-preserve"}));
1685        let ctx = ToolExecutionContext {
1686            session_id: Some("s-explicit-delete-deny"),
1687            tool_call_id: &call.id,
1688            event_tx: None,
1689            available_tool_schemas: None,
1690            bypass_permissions: true,
1691            auto_approve_permissions: false,
1692            plan_read_only: false,
1693            can_async_resume: false,
1694            bash_completion_sink: None,
1695            pre_parsed_args: None,
1696        };
1697
1698        let result = executor.execute_with_context(&call, ctx).await;
1699        assert!(
1700            matches!(result, Err(ToolError::Execution(ref message)) if message.contains("explicit policy")),
1701            "explicit delete deny must beat bypass: {result:?}"
1702        );
1703    }
1704
1705    #[tokio::test]
1706    async fn plan_auto_denies_mutation_but_allows_read_without_a_checker() {
1707        let executor = BuiltinToolExecutor::new();
1708        let dir = tempfile::tempdir().unwrap();
1709        let path = dir.path().join("plan-auto.txt");
1710        let write = make_tool_call(
1711            "Write",
1712            json!({"file_path": path, "content": "must not run"}),
1713        );
1714        let write_ctx = ToolExecutionContext {
1715            session_id: Some("plan-auto"),
1716            tool_call_id: &write.id,
1717            event_tx: None,
1718            available_tool_schemas: None,
1719            bypass_permissions: false,
1720            auto_approve_permissions: true,
1721            plan_read_only: true,
1722            can_async_resume: false,
1723            bash_completion_sink: None,
1724            pre_parsed_args: None,
1725        };
1726        let denied = executor.execute_with_context(&write, write_ctx).await;
1727        assert!(matches!(
1728            denied,
1729            Err(ToolError::Execution(ref message)) if message.contains("Plan mode")
1730        ));
1731        assert!(tokio::fs::metadata(&path).await.is_err());
1732
1733        tokio::fs::write(&path, "readable").await.unwrap();
1734        let read = make_tool_call("Read", json!({"file_path": path}));
1735        let read_ctx = ToolExecutionContext {
1736            session_id: Some("plan-auto"),
1737            tool_call_id: &read.id,
1738            event_tx: None,
1739            available_tool_schemas: None,
1740            bypass_permissions: false,
1741            auto_approve_permissions: true,
1742            plan_read_only: true,
1743            can_async_resume: false,
1744            bash_completion_sink: None,
1745            pre_parsed_args: None,
1746        };
1747        let allowed = executor
1748            .execute_with_context(&read, read_ctx)
1749            .await
1750            .unwrap();
1751        assert!(allowed.success);
1752    }
1753
1754    #[tokio::test]
1755    async fn auto_request_permissions_fails_without_creating_a_pause() {
1756        let executor = BuiltinToolExecutor::new();
1757        let (event_tx, mut event_rx) = mpsc::channel(4);
1758        let call = make_tool_call("request_permissions", json!({}));
1759        let ctx = ToolExecutionContext {
1760            session_id: Some("auto-no-prompt"),
1761            tool_call_id: &call.id,
1762            event_tx: Some(&event_tx),
1763            available_tool_schemas: None,
1764            bypass_permissions: false,
1765            auto_approve_permissions: true,
1766            plan_read_only: false,
1767            can_async_resume: false,
1768            bash_completion_sink: None,
1769            pre_parsed_args: None,
1770        };
1771
1772        let result = executor.execute_with_context_outcome(&call, ctx).await;
1773        assert!(matches!(
1774            result,
1775            Err(ToolError::Execution(ref message)) if message.contains("cannot request expanded permissions")
1776        ));
1777        assert!(event_rx.try_recv().is_err());
1778    }
1779
1780    #[tokio::test]
1781    async fn interactive_gate_returns_synthesized_approval_pause() {
1782        // With an event sink present, a forced-ask rule that yields
1783        // `ConfirmationRequired` must resolve to the synthesized "awaiting
1784        // approval" PAUSE result (a `Completed` result tagged
1785        // `display_preference = "request_permissions"`) — NOT an error — so the
1786        // engine turns it into a clarification pause. This locks in the
1787        // interactive-sink path that the `check_permissions_for` extraction must
1788        // preserve as `Ok(Some(outcome))` rather than collapse to an `Err`.
1789        let config = Arc::new(crate::permission::PermissionConfig::new());
1790        config.set_ask_rules(["Write(/etc/**)".to_string()]);
1791        config.register_session_workspace("s-interactive", "/workspace/project");
1792        let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
1793        let executor = make_executor(Some(checker));
1794
1795        let (tx, mut rx) = mpsc::channel(8);
1796        let call = make_tool_call(
1797            "Write",
1798            json!({"file_path": "/etc/gated.conf", "content": "x"}),
1799        );
1800        let ctx = ToolExecutionContext {
1801            session_id: Some("s-interactive"),
1802            tool_call_id: &call.id,
1803            event_tx: Some(&tx),
1804            available_tool_schemas: None,
1805            bypass_permissions: false,
1806            auto_approve_permissions: false,
1807            plan_read_only: false,
1808            can_async_resume: false,
1809            bash_completion_sink: None,
1810            pre_parsed_args: None,
1811        };
1812
1813        let result = executor
1814            .execute_with_context(&call, ctx)
1815            .await
1816            .expect("interactive gate should pause (Ok), not error");
1817
1818        assert_eq!(
1819            result.display_preference.as_deref(),
1820            Some("request_permissions"),
1821            "interactive gate must return the request_permissions pause result"
1822        );
1823        assert!(result.result.contains("awaiting_permission_approval"));
1824        let payload: serde_json::Value = serde_json::from_str(&result.result).expect("payload");
1825        let request = &payload["permission_request"];
1826        assert_eq!(request["request_id"], call.id);
1827        assert_eq!(request["session_id"], "s-interactive");
1828        assert_eq!(request["workspace_path"], "/workspace/project");
1829        assert_eq!(request["reason_code"], "configured_always_ask");
1830        assert_eq!(
1831            request["allowed_decisions"],
1832            json!(["allow_once", "deny_once"])
1833        );
1834        assert_eq!(payload["options"], json!(["Approve", "Deny"]));
1835        assert!(fs::metadata("/etc/gated.conf").await.is_err());
1836
1837        let ev = rx.recv().await.expect("approval event should be emitted");
1838        assert!(
1839            matches!(ev, AgentEvent::ToolApprovalRequested { tool_name, .. } if tool_name == "Write")
1840        );
1841    }
1842
1843    #[tokio::test]
1844    async fn proactive_permission_batch_uses_typed_remembered_scopes_then_completes() {
1845        let config = Arc::new(crate::permission::PermissionConfig::new());
1846        config.set_session_workspace("proactive-session", Some("/workspace/project".to_string()));
1847        let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(
1848            config.clone(),
1849        ));
1850        let executor = BuiltinToolExecutorBuilder::new()
1851            .with_tool(crate::tools::RequestPermissionsTool::new())
1852            .expect("register request_permissions")
1853            .with_permission_checker(checker)
1854            .build();
1855        let call = make_tool_call(
1856            "request_permissions",
1857            json!({
1858                "reason": "Deploy the service",
1859                "permissions": [
1860                    {
1861                        "type": "execute_command",
1862                        "resource": "docker compose up -d"
1863                    },
1864                    {
1865                        "type": "http_request",
1866                        "resource": "registry.example.com"
1867                    }
1868                ]
1869            }),
1870        );
1871        let (event_tx, _event_rx) = mpsc::channel(8);
1872
1873        let first = executor
1874            .execute_with_context(
1875                &call,
1876                ToolExecutionContext {
1877                    session_id: Some("proactive-session"),
1878                    tool_call_id: &call.id,
1879                    event_tx: Some(&event_tx),
1880                    available_tool_schemas: None,
1881                    bypass_permissions: false,
1882                    auto_approve_permissions: false,
1883                    plan_read_only: false,
1884                    can_async_resume: false,
1885                    bash_completion_sink: None,
1886                    pre_parsed_args: None,
1887                },
1888            )
1889            .await
1890            .expect("first batch context pauses");
1891        let first_payload: serde_json::Value = serde_json::from_str(&first.result).unwrap();
1892        let first_request = &first_payload["permission_request"];
1893        assert_eq!(first_request["resource"], "docker compose up -d");
1894        assert!(!first_request["allowed_decisions"]
1895            .as_array()
1896            .unwrap()
1897            .contains(&json!("allow_once")));
1898        assert!(first_request["allowed_decisions"]
1899            .as_array()
1900            .unwrap()
1901            .contains(&json!("allow_session")));
1902        let first_matcher: crate::permission::PermissionMatcher =
1903            serde_json::from_value(first_request["suggested_matchers"][0].clone()).unwrap();
1904        config
1905            .grant_typed_scoped_session_permission(
1906                "proactive-session",
1907                crate::permission::PermissionType::ExecuteCommand,
1908                first_matcher,
1909            )
1910            .unwrap();
1911
1912        let second = executor
1913            .execute_with_context(
1914                &call,
1915                ToolExecutionContext {
1916                    session_id: Some("proactive-session"),
1917                    tool_call_id: &call.id,
1918                    event_tx: Some(&event_tx),
1919                    available_tool_schemas: None,
1920                    bypass_permissions: false,
1921                    auto_approve_permissions: false,
1922                    plan_read_only: false,
1923                    can_async_resume: false,
1924                    bash_completion_sink: None,
1925                    pre_parsed_args: None,
1926                },
1927            )
1928            .await
1929            .expect("second batch context pauses");
1930        let second_payload: serde_json::Value = serde_json::from_str(&second.result).unwrap();
1931        let second_request = &second_payload["permission_request"];
1932        assert_eq!(second_request["resource"], "registry.example.com");
1933        let second_matcher: crate::permission::PermissionMatcher =
1934            serde_json::from_value(second_request["suggested_matchers"][0].clone()).unwrap();
1935        config
1936            .grant_typed_scoped_session_permission(
1937                "proactive-session",
1938                crate::permission::PermissionType::HttpRequest,
1939                second_matcher,
1940            )
1941            .unwrap();
1942
1943        let completed = executor
1944            .execute_with_context(
1945                &call,
1946                ToolExecutionContext {
1947                    session_id: Some("proactive-session"),
1948                    tool_call_id: &call.id,
1949                    event_tx: Some(&event_tx),
1950                    available_tool_schemas: None,
1951                    bypass_permissions: false,
1952                    auto_approve_permissions: false,
1953                    plan_read_only: false,
1954                    can_async_resume: false,
1955                    bash_completion_sink: None,
1956                    pre_parsed_args: None,
1957                },
1958            )
1959            .await
1960            .expect("all authorized contexts complete the tool");
1961        assert!(completed.display_preference.is_none());
1962        let completed_payload: serde_json::Value = serde_json::from_str(&completed.result).unwrap();
1963        assert_eq!(completed_payload["status"], "permissions_authorized");
1964        assert_eq!(
1965            completed_payload["permissions"].as_array().unwrap().len(),
1966            2
1967        );
1968    }
1969
1970    #[tokio::test]
1971    async fn workspace_permission_scope_uses_only_registered_session_identity() {
1972        let registered = Arc::new(crate::permission::PermissionConfig::new());
1973        registered.register_session_workspace("registered", "/workspace/authoritative");
1974        let registered_executor = make_executor(Some(Arc::new(
1975            crate::permission::ConfigPermissionChecker::new(registered.clone()),
1976        )));
1977
1978        let first = permission_request_payload(
1979            &registered_executor,
1980            "registered",
1981            json!({
1982                "file_path": "/tmp/first.txt",
1983                "content": "x",
1984                "cwd": "/model/chosen-a",
1985                "workspace_path": "/model/chosen-b"
1986            }),
1987        )
1988        .await;
1989        let second = permission_request_payload(
1990            &registered_executor,
1991            "registered",
1992            json!({
1993                "file_path": "/tmp/second.txt",
1994                "content": "x",
1995                "cwd": "/model/chosen-c"
1996            }),
1997        )
1998        .await;
1999        for payload in [&first, &second] {
2000            let request = &payload["permission_request"];
2001            assert_eq!(request["workspace_path"], "/workspace/authoritative");
2002            assert!(request["allowed_decisions"]
2003                .as_array()
2004                .unwrap()
2005                .contains(&json!("allow_workspace")));
2006        }
2007
2008        registered.set_session_workspace("registered", None);
2009        let unbound = permission_request_payload(
2010            &registered_executor,
2011            "registered",
2012            json!({
2013                "file_path": "/tmp/unbound.txt",
2014                "content": "x",
2015                "cwd": "/workspace/authoritative"
2016            }),
2017        )
2018        .await;
2019        assert!(unbound["permission_request"]["workspace_path"].is_null());
2020        assert!(!unbound["permission_request"]["allowed_decisions"]
2021            .as_array()
2022            .unwrap()
2023            .contains(&json!("allow_workspace")));
2024
2025        registered.set_session_workspace("registered", Some("/workspace/rebound".to_string()));
2026        let rebound = permission_request_payload(
2027            &registered_executor,
2028            "registered",
2029            json!({
2030                "file_path": "/tmp/rebound.txt",
2031                "content": "x",
2032                "workspace_path": "/workspace/authoritative"
2033            }),
2034        )
2035        .await;
2036        assert_eq!(
2037            rebound["permission_request"]["workspace_path"],
2038            "/workspace/rebound"
2039        );
2040
2041        let unregistered = Arc::new(crate::permission::PermissionConfig::new());
2042        let unregistered_executor = make_executor(Some(Arc::new(
2043            crate::permission::ConfigPermissionChecker::new(unregistered),
2044        )));
2045        let payload = permission_request_payload(
2046            &unregistered_executor,
2047            "unregistered",
2048            json!({
2049                "file_path": "/tmp/unregistered.txt",
2050                "content": "x",
2051                "cwd": "/model/chosen",
2052                "workspace_path": "/also/model/chosen"
2053            }),
2054        )
2055        .await;
2056        let request = &payload["permission_request"];
2057        assert!(request["workspace_path"].is_null());
2058        assert!(!request["allowed_decisions"]
2059            .as_array()
2060            .unwrap()
2061            .contains(&json!("allow_workspace")));
2062    }
2063
2064    #[tokio::test]
2065    async fn check_permissions_for_returns_none_when_permitted() {
2066        // A tool with no matching gate (Read, no checker rule) passes the gate:
2067        // `check_permissions_for` returns `Ok(None)` so the caller runs the tool.
2068        let executor = make_executor(None);
2069        let call = make_tool_call("Read", json!({"file_path": "/tmp/whatever"}));
2070        let ctx = ToolExecutionContext::none(&call.id);
2071        let decision = executor
2072            .check_permissions_for(&call, &ctx)
2073            .await
2074            .expect("no checker means no gate");
2075        assert!(decision.is_none(), "no checker must yield Ok(None)");
2076    }
2077
2078    // ---- Phase 2: cross-process approval proxy ----------------------------
2079
2080    struct HostStub {
2081        approve: bool,
2082    }
2083
2084    #[async_trait]
2085    impl crate::approval::ApprovalProxy for HostStub {
2086        async fn request_approval(&self, _ask: crate::approval::ApprovalAsk) -> bool {
2087            self.approve
2088        }
2089    }
2090
2091    #[tokio::test]
2092    async fn approval_proxy_grant_lets_gated_tool_proceed() {
2093        // A subagent worker installs an ApprovalProxy for its run. A forced-ask
2094        // rule with NO event sink would otherwise fail closed; with the host
2095        // proxy granting, the executor treats the context as approved and the
2096        // tool proceeds inline (no suspend, no synthetic pause).
2097        let dir = tempfile::tempdir().unwrap();
2098        let path = dir.path().join("approved.txt");
2099        let path_str = path.to_str().unwrap().to_string();
2100        let config = Arc::new(crate::permission::PermissionConfig::new());
2101        config.set_ask_rules([format!("Write({}/**)", dir.path().to_str().unwrap())]);
2102        let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
2103        let executor = make_executor(Some(checker));
2104
2105        let call = make_tool_call("Write", json!({"file_path": path_str, "content": "ok"}));
2106        let ctx = ToolExecutionContext {
2107            session_id: Some("s-worker"),
2108            tool_call_id: &call.id,
2109            event_tx: None,
2110            available_tool_schemas: None,
2111            bypass_permissions: false,
2112            auto_approve_permissions: false,
2113            plan_read_only: false,
2114            can_async_resume: false,
2115            bash_completion_sink: None,
2116            pre_parsed_args: None,
2117        };
2118
2119        let proxy: Arc<dyn crate::approval::ApprovalProxy> = Arc::new(HostStub { approve: true });
2120        let result = crate::approval::with_approval_proxy(
2121            Some(proxy),
2122            executor.execute_with_context(&call, ctx),
2123        )
2124        .await;
2125
2126        assert!(
2127            result.is_ok(),
2128            "host grant should let the write through: {result:?}"
2129        );
2130        assert_eq!(fs::read_to_string(&path).await.unwrap(), "ok");
2131    }
2132
2133    #[tokio::test]
2134    async fn approval_proxy_deny_fails_gated_tool_closed() {
2135        // With the host proxy denying, the gated tool fails closed and the side
2136        // effect never happens.
2137        let dir = tempfile::tempdir().unwrap();
2138        let path = dir.path().join("denied.txt");
2139        let path_str = path.to_str().unwrap().to_string();
2140        let config = Arc::new(crate::permission::PermissionConfig::new());
2141        config.set_ask_rules([format!("Write({}/**)", dir.path().to_str().unwrap())]);
2142        let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
2143        let executor = make_executor(Some(checker));
2144
2145        let call = make_tool_call("Write", json!({"file_path": path_str, "content": "nope"}));
2146        let ctx = ToolExecutionContext {
2147            session_id: Some("s-worker"),
2148            tool_call_id: &call.id,
2149            event_tx: None,
2150            available_tool_schemas: None,
2151            bypass_permissions: false,
2152            auto_approve_permissions: false,
2153            plan_read_only: false,
2154            can_async_resume: false,
2155            bash_completion_sink: None,
2156            pre_parsed_args: None,
2157        };
2158
2159        let proxy: Arc<dyn crate::approval::ApprovalProxy> = Arc::new(HostStub { approve: false });
2160        let result = crate::approval::with_approval_proxy(
2161            Some(proxy),
2162            executor.execute_with_context(&call, ctx),
2163        )
2164        .await;
2165
2166        assert!(
2167            matches!(result, Err(ToolError::Execution(ref m)) if m.contains("denied by host")),
2168            "host deny should fail the tool closed: {result:?}"
2169        );
2170        assert!(fs::metadata(&path).await.is_err());
2171    }
2172
2173    #[tokio::test]
2174    async fn tool_can_stream_events_via_execute_with_context() {
2175        struct StreamingTool;
2176
2177        #[async_trait]
2178        impl Tool for StreamingTool {
2179            fn name(&self) -> &str {
2180                "streaming_tool"
2181            }
2182
2183            fn description(&self) -> &str {
2184                "streams one token"
2185            }
2186
2187            fn parameters_schema(&self) -> serde_json::Value {
2188                json!({"type":"object","properties":{}})
2189            }
2190
2191            async fn invoke(
2192                &self,
2193                _args: serde_json::Value,
2194                ctx: ToolCtx,
2195            ) -> Result<ToolOutcome, ToolError> {
2196                ctx.emit(AgentEvent::Token {
2197                    content: "stream".to_string(),
2198                })
2199                .await;
2200                Ok(ToolOutcome::Completed(ToolResult {
2201                    success: true,
2202                    result: "ok".to_string(),
2203                    display_preference: None,
2204                    images: Vec::new(),
2205                }))
2206            }
2207        }
2208
2209        let executor = BuiltinToolExecutor::new();
2210        executor
2211            .register_tool(StreamingTool)
2212            .expect("register streaming tool");
2213
2214        let (tx, mut rx) = mpsc::channel(8);
2215        let call = make_tool_call("streaming_tool", json!({}));
2216
2217        let result = executor
2218            .execute_with_context(
2219                &call,
2220                ToolExecutionContext {
2221                    session_id: Some("s1"),
2222                    tool_call_id: &call.id,
2223                    event_tx: Some(&tx),
2224                    available_tool_schemas: None,
2225                    bypass_permissions: false,
2226                    auto_approve_permissions: false,
2227                    plan_read_only: false,
2228                    can_async_resume: false,
2229                    bash_completion_sink: None,
2230                    pre_parsed_args: None,
2231                },
2232            )
2233            .await
2234            .expect("execute tool");
2235
2236        assert!(result.success);
2237        assert_eq!(result.result, "ok");
2238
2239        let ev = rx.recv().await.expect("expected streamed event");
2240        assert!(
2241            matches!(ev, AgentEvent::ToolToken { tool_call_id, content } if tool_call_id == "call_1" && content == "stream")
2242        );
2243    }
2244
2245    #[tokio::test]
2246    async fn removed_legacy_tools_return_not_found() {
2247        let executor = BuiltinToolExecutor::new();
2248
2249        for legacy in ["claude_code", "search_in_file", "search_in_project"] {
2250            let call = make_tool_call(legacy, json!({}));
2251            let result = executor.execute(&call).await;
2252            assert!(matches!(result, Err(ToolError::NotFound(_))));
2253        }
2254    }
2255
2256    #[tokio::test]
2257    async fn executor_prefers_exact_tool_name_before_builtin_alias() {
2258        struct CustomSpawnSessionTool;
2259
2260        #[async_trait]
2261        impl Tool for CustomSpawnSessionTool {
2262            fn name(&self) -> &str {
2263                "spawn_session"
2264            }
2265
2266            fn description(&self) -> &str {
2267                "custom tool for regression coverage"
2268            }
2269
2270            fn parameters_schema(&self) -> serde_json::Value {
2271                json!({"type":"object","properties":{}})
2272            }
2273
2274            async fn invoke(
2275                &self,
2276                _args: serde_json::Value,
2277                _ctx: ToolCtx,
2278            ) -> Result<ToolOutcome, ToolError> {
2279                Ok(ToolOutcome::Completed(ToolResult {
2280                    success: true,
2281                    result: "custom-spawn-session".to_string(),
2282                    display_preference: None,
2283                    images: Vec::new(),
2284                }))
2285            }
2286        }
2287
2288        let executor = BuiltinToolExecutorBuilder::new()
2289            .with_tool(CustomSpawnSessionTool)
2290            .expect("register custom spawn_session tool")
2291            .build();
2292
2293        let call = make_tool_call("spawn_session", json!({}));
2294        let result = executor.execute(&call).await.expect("execute custom tool");
2295        assert!(result.success);
2296        assert_eq!(result.result, "custom-spawn-session");
2297    }
2298
2299    // ---- issue #106: parse tool args once on the execute path -------------
2300
2301    /// A tool that echoes back the `v` field of the args it was invoked with, so
2302    /// a test can observe *which* parsed value reached the tool.
2303    struct EchoArgsTool;
2304
2305    #[async_trait]
2306    impl Tool for EchoArgsTool {
2307        fn name(&self) -> &str {
2308            "echo_args"
2309        }
2310        fn description(&self) -> &str {
2311            "echoes the `v` arg"
2312        }
2313        fn parameters_schema(&self) -> serde_json::Value {
2314            json!({"type":"object","properties":{"v":{"type":"string"}}})
2315        }
2316        async fn invoke(
2317            &self,
2318            args: serde_json::Value,
2319            _ctx: ToolCtx,
2320        ) -> Result<ToolOutcome, ToolError> {
2321            let v = args
2322                .get("v")
2323                .and_then(serde_json::Value::as_str)
2324                .unwrap_or("<none>")
2325                .to_string();
2326            Ok(ToolOutcome::Completed(ToolResult {
2327                success: true,
2328                result: v,
2329                display_preference: None,
2330                images: Vec::new(),
2331            }))
2332        }
2333    }
2334
2335    fn ctx_with_pre_parsed<'a>(
2336        call_id: &'a str,
2337        pre_parsed: Option<&'a serde_json::Value>,
2338    ) -> ToolExecutionContext<'a> {
2339        ToolExecutionContext {
2340            session_id: Some("s-106"),
2341            tool_call_id: call_id,
2342            event_tx: None,
2343            available_tool_schemas: None,
2344            bypass_permissions: false,
2345            auto_approve_permissions: false,
2346            plan_read_only: false,
2347            can_async_resume: false,
2348            bash_completion_sink: None,
2349            pre_parsed_args: pre_parsed,
2350        }
2351    }
2352
2353    #[tokio::test]
2354    async fn execute_with_context_reuses_pre_parsed_args_without_reparsing() {
2355        // The raw `arguments` string and the threaded `pre_parsed_args` Value
2356        // deliberately disagree. If the executor honored the contract (parse
2357        // once at the dispatch site, reuse downstream), the tool sees the
2358        // pre-parsed value; if it re-parsed the raw string it would see "raw".
2359        // This is the load-bearing proof that the second parse was eliminated.
2360        let executor = BuiltinToolExecutor::new();
2361        executor.register_tool(EchoArgsTool).expect("register echo");
2362
2363        let call = make_tool_call("echo_args", json!({"v": "raw"}));
2364        let pre_parsed = json!({"v": "preparsed"});
2365        let ctx = ctx_with_pre_parsed(&call.id, Some(&pre_parsed));
2366
2367        let result = executor
2368            .execute_with_context(&call, ctx)
2369            .await
2370            .expect("execute echo tool");
2371        assert_eq!(
2372            result.result, "preparsed",
2373            "executor must reuse pre_parsed_args, not re-parse the raw string"
2374        );
2375    }
2376
2377    #[tokio::test]
2378    async fn execute_with_context_parses_raw_when_no_pre_parsed_args() {
2379        // Without a threaded value (the `execute` entry point / tests / a loop
2380        // that parsed with a different parser), the executor falls back to
2381        // parsing the raw string exactly as before — behavior preserved.
2382        let executor = BuiltinToolExecutor::new();
2383        executor.register_tool(EchoArgsTool).expect("register echo");
2384
2385        let call = make_tool_call("echo_args", json!({"v": "raw"}));
2386        let ctx = ctx_with_pre_parsed(&call.id, None);
2387
2388        let result = executor
2389            .execute_with_context(&call, ctx)
2390            .await
2391            .expect("execute echo tool");
2392        assert_eq!(
2393            result.result, "raw",
2394            "without pre_parsed_args the executor parses the raw string as before"
2395        );
2396    }
2397
2398    #[tokio::test]
2399    async fn execute_with_context_malformed_args_repair_unchanged_without_pre_parsed() {
2400        // Malformed (truncated) JSON must still be auto-repaired by the
2401        // fallback parse when no pre-parsed value is threaded — the existing
2402        // error/leniency behavior is untouched by the dedup.
2403        let dir = tempfile::tempdir().unwrap();
2404        let path = dir.path().join("recovered-no-preparsed.txt");
2405        let malformed_args = format!(
2406            r#"{{"file_path":"{}","content":"recovered content""#,
2407            path.display()
2408        );
2409
2410        let executor = BuiltinToolExecutor::new();
2411        let call = make_tool_call_with_raw_args("Write", &malformed_args);
2412        let ctx = ctx_with_pre_parsed(&call.id, None);
2413
2414        let result = executor
2415            .execute_with_context(&call, ctx)
2416            .await
2417            .expect("truncated JSON should be auto-repaired");
2418        assert!(result.success);
2419        let written = fs::read_to_string(&path).await.expect("file written");
2420        assert_eq!(written, "recovered content");
2421    }
2422}