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    /// Returns a reference to the internal registry
156    pub fn registry(&self) -> &ToolRegistry {
157        &self.registry
158    }
159
160    /// Registers all built-in tools to the given registry
161    fn register_builtin_tools(registry: &ToolRegistry, config: Option<Arc<RwLock<Config>>>) {
162        let _ = config;
163        // NOTE: apply_patch is now an alias for Edit – no separate registration.
164        let _ = registry.register(ConclusionWithOptionsTool::new());
165        let _ = registry.register(BashTool::new());
166        let _ = registry.register(BashInputTool::new());
167        let _ = registry.register(BashOutputTool::new());
168        let _ = registry.register(EditTool::new());
169        let _ = registry.register(EnterPlanModeTool::new());
170        let _ = registry.register(ExitPlanModeTool::new());
171        // NOTE: FileExists is now an alias for GetFileInfo – no separate registration.
172        let _ = registry.register(GetFileInfoTool::new());
173        let _ = registry.register(GlobTool::new());
174        let _ = registry.register(GrepTool::new());
175        let _ = registry.register(UpdateGoalTool::new());
176        let _ = registry.register(JsReplTool::new());
177        let _ = registry.register(KillShellTool::new());
178        let _ = registry.register(SessionNoteTool::new());
179        let _ = registry.register(NotebookEditTool::new());
180        let _ = registry.register(ReadTool::new());
181        let _ = registry.register(RequestPermissionsTool::new());
182        let _ = registry.register(SleepTool::new());
183        let _ = registry.register(TaskTool::new());
184        let _ = registry.register(WebFetchTool::new());
185        let _ = registry.register(WebSearchTool::new());
186        // NOTE: GetCurrentDir + SetWorkspace are now aliases for Workspace.
187        let _ = registry.register(WorkspaceTool::new());
188        let _ = registry.register(WriteTool::new());
189    }
190
191    /// Returns all built-in tool schemas
192    pub fn tool_schemas() -> Vec<ToolSchema> {
193        let registry = ToolRegistry::new();
194        Self::register_builtin_tools(&registry, None);
195        registry.list_tools()
196    }
197
198    /// Registers a custom tool to this executor
199    pub fn register_tool<T: Tool + 'static>(&self, tool: T) -> Result<(), ToolError> {
200        self.registry
201            .register(tool)
202            .map_err(|e| ToolError::Execution(e.to_string()))
203    }
204
205    /// Register a tool with its guide
206    pub fn register_tool_with_guide<T, G>(&self, tool: T, guide: G) -> Result<(), ToolError>
207    where
208        T: Tool + 'static,
209        G: ToolGuide + 'static,
210    {
211        self.registry
212            .register_with_guide(tool, guide)
213            .map_err(|e| ToolError::Execution(e.to_string()))
214    }
215
216    /// Get guide for a tool
217    pub fn get_guide(&self, tool_name: &str) -> Option<Arc<dyn ToolGuide>> {
218        self.registry.get_guide(tool_name)
219    }
220
221    /// Build enhanced prompt for all registered tools
222    pub fn build_enhanced_prompt(&self, context: GuideBuildContext) -> String {
223        EnhancedPromptBuilder::build(Some(&self.registry), &self.registry.list_tools(), &context)
224    }
225}
226
227fn permission_error_to_tool_error(error: PermissionError) -> ToolError {
228    match error {
229        PermissionError::CheckFailed(_) => ToolError::InvalidArguments(error.to_string()),
230        _ => ToolError::Execution(error.to_string()),
231    }
232}
233
234impl Default for BuiltinToolExecutor {
235    fn default() -> Self {
236        Self::new()
237    }
238}
239
240#[async_trait]
241impl ToolExecutor for BuiltinToolExecutor {
242    async fn execute(&self, call: &ToolCall) -> Result<ToolResult, ToolError> {
243        self.execute_with_context(call, ToolExecutionContext::none(&call.id))
244            .await
245    }
246
247    async fn execute_with_context(
248        &self,
249        call: &ToolCall,
250        ctx: ToolExecutionContext<'_>,
251    ) -> Result<ToolResult, ToolError> {
252        self.execute_with_context_outcome(call, ctx)
253            .await
254            .map(ToolOutcome::into_tool_result)
255    }
256
257    async fn execute_with_context_outcome(
258        &self,
259        call: &ToolCall,
260        ctx: ToolExecutionContext<'_>,
261    ) -> Result<ToolOutcome, ToolError> {
262        // Reuse the args the dispatching agent loop already parsed (for the
263        // `ToolStart` event) when it threaded them through the context, instead
264        // of re-parsing the raw JSON string here (issue #106, deferred B1 from
265        // #17). The pre-parsed value is the exact output of
266        // `parse_tool_args_best_effort` on the same input, and that loop already
267        // logged any fallback warning at parse time, so skipping the re-parse is
268        // behavior-preserving. When absent (the `execute` entry point, tests, or
269        // a loop that parsed with a different/stricter parser), fall back to
270        // parsing here exactly as before — including the fallback-warning log.
271        let mut args = if let Some(pre_parsed) = ctx.pre_parsed_args {
272            pre_parsed.clone()
273        } else {
274            let args_raw = call.function.arguments.trim();
275            let (parsed, parse_warning) = parse_tool_args_best_effort(&call.function.arguments);
276            if let Some(warning) = parse_warning {
277                tracing::warn!(
278                    "Builtin tool argument parsing fallback applied: session_id={:?}, tool_call_id={}, tool_name={}, args_len={}, args_preview=\"{}\", warning={}",
279                    ctx.session_id,
280                    call.id,
281                    call.function.name,
282                    args_raw.len(),
283                    preview_for_log(args_raw, 180),
284                    warning
285                );
286            }
287            parsed
288        };
289
290        let raw_tool_name = normalize_tool_name(&call.function.name);
291        if let Some(args_obj) = args.as_object_mut() {
292            normalize_legacy_builtin_args(raw_tool_name, args_obj);
293        }
294
295        let tool_name = resolve_registered_tool_name(&self.registry, raw_tool_name);
296
297        // Look up the tool in the registry
298        let tool = self
299            .registry
300            .get(&tool_name)
301            .ok_or_else(|| ToolError::NotFound(format!("Tool '{}' not found", tool_name)))?;
302
303        // Permission gate. Factored onto the `ToolExecutor` trait
304        // (`check_permissions_for`) so overlay/wrapping executors can run the
305        // exact same check before invoking their own tools (issue #341). Kept
306        // AFTER the registry lookup so a `NotFound` still takes precedence,
307        // exactly as before. `Some(outcome)` is the interactive approval pause
308        // synthesized for a human sink; `Err` is deny / fail-closed.
309        if let Some(outcome) = self.check_permissions_for(call, &ctx).await? {
310            return Ok(outcome);
311        }
312
313        // Rewritten dispatch: build the owned `ToolCtx` at this concrete seam and
314        // call the tool's single `invoke`. Unwrap the `ToolOutcome` back to a
315        // `ToolResult` so the surrounding dispatch/loop is unchanged for now:
316        // `Completed` is the result; `Running`'s synthetic ack IS a `ToolResult`
317        // (preserving background Bash's current behavior); `NeedsHuman` cannot yet
318        // be produced (no tool returns it in this phase). Phase B makes the outcome
319        // authoritative and removes this unwrap.
320        let tool_ctx = ctx.to_tool_ctx();
321        tool.invoke(args, tool_ctx).await
322    }
323
324    /// The real permission gate for built-in tools, extracted from the execute
325    /// path so it is reusable by wrapping executors (issue #341). The behavior is
326    /// byte-for-byte the same block that used to run inline in
327    /// `execute_with_context_outcome`:
328    ///
329    /// - resolves the SAME `tool_name` + `args` the execute path runs with (so
330    ///   the check sees exactly what the tool will run with);
331    /// - "always ask" rules (`requires_forced_confirmation`) force a confirmation
332    ///   even under bypass; everything else is skipped when the session is in
333    ///   bypass-permissions mode;
334    /// - forced confirmations route through `check_or_request_forced` so the
335    ///   active mode/bypass can't suppress the prompt;
336    /// - a `ConfirmationRequired` first tries the cross-process `ApprovalProxy`
337    ///   (a subagent worker forwarding to its host), then the interactive human
338    ///   sink (returning the synthesized approval pause as `Ok(Some(..))`), then
339    ///   fails closed;
340    /// - deny fails closed.
341    ///
342    /// The only mechanical difference from the old inline block: the interactive
343    /// pause is returned as `Ok(Some(outcome))` and a clean pass returns
344    /// `Ok(None)`, so the caller decides whether to run the tool. The fallback
345    /// arg-parse warning is intentionally NOT re-logged here — the execute path
346    /// already logs it once for this call.
347    async fn check_permissions_for(
348        &self,
349        call: &ToolCall,
350        ctx: &ToolExecutionContext<'_>,
351    ) -> Result<Option<ToolOutcome>, ToolError> {
352        let Some(permission_checker) = &self.permission_checker else {
353            return Ok(None);
354        };
355        let hook_permission_override = crate::current_hook_permission_override(&call.id);
356
357        // Mirror the head of `execute_with_context_outcome`: reuse the pre-parsed
358        // args when threaded, apply the legacy-arg normalization, then resolve the
359        // registered/alias tool name. This is what makes the gate see the exact
360        // `tool_name`/`args` the tool will actually run with.
361        let mut args = if let Some(pre_parsed) = ctx.pre_parsed_args {
362            pre_parsed.clone()
363        } else {
364            parse_tool_args_best_effort(&call.function.arguments).0
365        };
366        let raw_tool_name = normalize_tool_name(&call.function.name);
367        if let Some(args_obj) = args.as_object_mut() {
368            normalize_legacy_builtin_args(raw_tool_name, args_obj);
369        }
370        let tool_name = resolve_registered_tool_name(&self.registry, raw_tool_name);
371
372        if let Some(contexts) =
373            check_permissions(&tool_name, &args).map_err(permission_error_to_tool_error)?
374        {
375            for context in contexts {
376                let resource = context.resource.clone();
377                let operation_summary = context.operation_description.clone();
378                let risk_level = context.risk_level();
379                let permission_type = context.permission_type;
380                let config = permission_checker.permission_config();
381                let proxy = crate::approval::current_approval_proxy();
382                let request = if let Some(config) = config.as_ref() {
383                    // A boolean approval relay can only honor one-shot choices.
384                    // Interactive local sessions support all typed scopes; the
385                    // evaluator omits workspace when no stable identity is known.
386                    let supported_decisions = if proxy.is_some() {
387                        crate::permission::PermissionRequest::forced_decisions()
388                    } else {
389                        crate::permission::PermissionRequest::ordinary_decisions(true)
390                    };
391                    let workspace_path = args
392                        .get("workspace_path")
393                        .or_else(|| args.get("cwd"))
394                        .and_then(|value| value.as_str())
395                        .map(ToOwned::to_owned)
396                        .or_else(|| {
397                            ctx.session_id
398                                .and_then(|session_id| config.session_workspace(session_id))
399                        });
400                    match config.evaluate(crate::permission::PermissionEvaluation {
401                        request_id: call.id.clone(),
402                        session_id: ctx.session_id.unwrap_or_default().to_string(),
403                        workspace_path,
404                        tool_name: tool_name.clone(),
405                        tool_args: args.clone(),
406                        permission_type,
407                        resource: resource.clone(),
408                        operation_summary: operation_summary.clone(),
409                        risk_level,
410                        bypass_requested: ctx.bypass_permissions,
411                        platform_hard_deny: None,
412                        consume_once: true,
413                        supported_decisions,
414                    }) {
415                        crate::permission::PermissionOutcome::Allow { .. } => continue,
416                        crate::permission::PermissionOutcome::Deny { reason, .. } => {
417                            return Err(ToolError::Execution(reason.message));
418                        }
419                        crate::permission::PermissionOutcome::Ask(request)
420                            if matches!(
421                                hook_permission_override,
422                                Some(crate::HookPermissionOverride::Allow)
423                            ) && request.reason_code
424                                != crate::permission::PermissionReasonCode::HardDangerous =>
425                        {
426                            continue;
427                        }
428                        crate::permission::PermissionOutcome::Ask(request) => request,
429                    }
430                } else {
431                    // Compatibility path for custom checkers that do not expose a
432                    // typed config. It remains one-shot only and fail-closed.
433                    let force_ask =
434                        permission_checker.requires_forced_confirmation(&tool_name, &args);
435                    let hook_allows = matches!(
436                        hook_permission_override,
437                        Some(crate::HookPermissionOverride::Allow)
438                    );
439                    if (ctx.bypass_permissions || hook_allows) && !force_ask {
440                        continue;
441                    }
442                    let decision = if force_ask {
443                        permission_checker.check_or_request_forced(context).await
444                    } else if let Some(session_id) = ctx.session_id {
445                        permission_checker
446                            .check_or_request_for_session(session_id, context)
447                            .await
448                    } else {
449                        permission_checker.check_or_request(context).await
450                    };
451                    match decision {
452                        Ok(true) => continue,
453                        Ok(false) => {
454                            return Err(ToolError::Execution(format!(
455                                "Permission denied for: {}",
456                                resource
457                            )));
458                        }
459                        Err(PermissionError::ConfirmationRequired { .. }) => {
460                            crate::permission::PermissionRequest {
461                                request_id: call.id.clone(),
462                                session_id: ctx.session_id.unwrap_or_default().to_string(),
463                                workspace_path: None,
464                                tool_name: tool_name.clone(),
465                                permission_type,
466                                resource: resource.clone(),
467                                operation_summary: operation_summary.clone(),
468                                risk_level,
469                                reason_code: if force_ask {
470                                    crate::permission::PermissionReasonCode::ConfiguredAlwaysAsk
471                                } else {
472                                    crate::permission::PermissionReasonCode::RiskThreshold
473                                },
474                                effective_mode: bamboo_config::settings::PermissionMode::Default,
475                                bypass_requested: ctx.bypass_permissions,
476                                policy_revision: 0,
477                                matched_rule: None,
478                                allowed_decisions:
479                                    crate::permission::PermissionRequest::forced_decisions(),
480                                suggested_matchers: crate::permission::conservative_matchers(
481                                    permission_type,
482                                    &resource,
483                                ),
484                            }
485                        }
486                        Err(other) => return Err(permission_error_to_tool_error(other)),
487                    }
488                };
489
490                // A worker/external relay gets the same typed request but only
491                // one-shot decisions are advertised until its protocol supports
492                // a stronger scope. No boolean downgrade can create a grant.
493                if let Some(proxy) = proxy {
494                    let approved = proxy
495                        .request_approval(crate::approval::ApprovalAsk {
496                            tool_name: tool_name.clone(),
497                            permission: permission_type.description().to_string(),
498                            resource: resource.clone(),
499                            permission_request: Some(request.clone()),
500                        })
501                        .await;
502                    if approved {
503                        continue;
504                    }
505                    return Err(ToolError::Execution(format!(
506                        "Permission denied by host for: {}",
507                        resource
508                    )));
509                }
510
511                // Interactive sessions pause through the legacy question shape
512                // while carrying the complete typed request alongside it.
513                if let Some(tx) = ctx.event_tx {
514                    let _ = tx
515                        .send(bamboo_agent_core::AgentEvent::ToolApprovalRequested {
516                            tool_call_id: call.id.clone(),
517                            tool_name: tool_name.clone(),
518                            parameters: args.clone(),
519                        })
520                        .await;
521
522                    let question = format!(
523                        "**Permission required**\n\nThe `{}` tool needs approval to {} on:\n\n`{}`",
524                        tool_name,
525                        permission_type.description(),
526                        resource
527                    );
528                    if let Some(config) = config {
529                        config.register_pending_request(request.clone());
530                    }
531                    let payload = serde_json::json!({
532                        "status": "awaiting_permission_approval",
533                        "question": question,
534                        "permission_type": permission_type,
535                        "resource": resource,
536                        "options": ["Approve", "Deny"],
537                        "allow_custom": false,
538                        "permission_request": request,
539                    });
540                    return Ok(Some(ToolOutcome::Completed(ToolResult {
541                        success: true,
542                        result: payload.to_string(),
543                        display_preference: Some("request_permissions".to_string()),
544                        images: Vec::new(),
545                    })));
546                }
547
548                return Err(ToolError::Execution(format!(
549                    "Permission approval required for: {}",
550                    resource
551                )));
552            }
553        }
554
555        Ok(None)
556    }
557
558    fn list_tools(&self) -> Vec<ToolSchema> {
559        self.registry.list_tools()
560    }
561
562    fn tool_mutability(&self, tool_name: &str) -> crate::ToolMutability {
563        self.registry
564            .get(tool_name)
565            .map(|tool| tool.classify(&serde_json::Value::Null).mutability)
566            .unwrap_or_else(|| crate::classify_tool(tool_name))
567    }
568
569    fn call_mutability(&self, call: &ToolCall) -> crate::ToolMutability {
570        let canonical = resolve_registered_tool_name(&self.registry, call.function.name.trim());
571        let args = bamboo_agent_core::parse_tool_args_best_effort(&call.function.arguments).0;
572        self.registry
573            .get(&canonical)
574            .map(|tool| tool.classify(&args).mutability)
575            .unwrap_or_else(|| self.tool_mutability(&canonical))
576    }
577
578    fn tool_concurrency_safe(&self, tool_name: &str) -> bool {
579        let canonical = resolve_registered_tool_name(&self.registry, tool_name);
580        self.registry
581            .get(&canonical)
582            .map(|tool| tool.classify(&serde_json::Value::Null).parallel_safe)
583            .unwrap_or_else(|| self.tool_mutability(&canonical) == crate::ToolMutability::ReadOnly)
584    }
585
586    fn call_concurrency_safe(&self, call: &ToolCall) -> bool {
587        let canonical = resolve_registered_tool_name(&self.registry, call.function.name.trim());
588        let args = bamboo_agent_core::parse_tool_args_best_effort(&call.function.arguments).0;
589        self.registry
590            .get(&canonical)
591            .map(|tool| tool.classify(&args).parallel_safe)
592            .unwrap_or_else(|| self.tool_concurrency_safe(&canonical))
593    }
594
595    fn call_parallel_classification(&self, call: &ToolCall) -> (crate::ToolMutability, bool) {
596        // One args-aware `classify` returns the (mutability, parallel_safe) pair
597        // with a single arg parse — the collapse of the former
598        // `call_mutability`/`call_concurrency_safe` pair.
599        let canonical = resolve_registered_tool_name(&self.registry, call.function.name.trim());
600        let args = bamboo_agent_core::parse_tool_args_best_effort(&call.function.arguments).0;
601        match self.registry.get(&canonical) {
602            Some(tool) => {
603                let class = tool.classify(&args);
604                (class.mutability, class.parallel_safe)
605            }
606            None => (
607                self.tool_mutability(&canonical),
608                self.tool_concurrency_safe(&canonical),
609            ),
610        }
611    }
612}
613
614/// Builder for constructing a BuiltinToolExecutor with custom tool configurations
615pub struct BuiltinToolExecutorBuilder {
616    registry: ToolRegistry,
617    permission_checker: Option<Arc<dyn PermissionChecker>>,
618}
619
620impl BuiltinToolExecutorBuilder {
621    /// Creates a new builder with no tools registered
622    pub fn new() -> Self {
623        Self {
624            registry: ToolRegistry::new(),
625            permission_checker: None,
626        }
627    }
628
629    /// Registers all default built-in tools
630    pub fn with_default_tools(self) -> Self {
631        BuiltinToolExecutor::register_builtin_tools(&self.registry, None);
632        self
633    }
634
635    /// Registers a specific filesystem tool by name
636    pub fn with_filesystem_tool(self, name: &str) -> Result<Self, ToolError> {
637        match name {
638            "Read" => self.registry.register(ReadTool::new()),
639            "Write" => self.registry.register(WriteTool::new()),
640            // apply_patch is now an alias for Edit
641            "Edit" | "apply_patch" => self.registry.register(EditTool::new()),
642            "NotebookEdit" => self.registry.register(NotebookEditTool::new()),
643            _ => return Err(ToolError::NotFound(format!("Unknown tool: {}", name))),
644        }
645        .map_err(|e| ToolError::Execution(e.to_string()))?;
646        Ok(self)
647    }
648
649    /// Registers a specific command tool by name
650    pub fn with_command_tool(self, name: &str) -> Result<Self, ToolError> {
651        match name {
652            "Bash" => self.registry.register(BashTool::new()),
653            "BashOutput" => self.registry.register(BashOutputTool::new()),
654            "KillShell" => self.registry.register(KillShellTool::new()),
655            "Task" => self.registry.register(TaskTool::new()),
656            _ => return Err(ToolError::NotFound(format!("Unknown tool: {}", name))),
657        }
658        .map_err(|e| ToolError::Execution(e.to_string()))?;
659        Ok(self)
660    }
661
662    /// Registers a custom tool
663    pub fn with_tool<T: Tool + 'static>(self, tool: T) -> Result<Self, ToolError> {
664        self.registry
665            .register(tool)
666            .map_err(|e| ToolError::Execution(e.to_string()))?;
667        Ok(self)
668    }
669
670    /// Sets a permission checker for this executor
671    pub fn with_permission_checker(mut self, checker: Arc<dyn PermissionChecker>) -> Self {
672        self.permission_checker = Some(checker);
673        self
674    }
675
676    /// Builds the executor
677    pub fn build(self) -> BuiltinToolExecutor {
678        BuiltinToolExecutor {
679            registry: self.registry,
680            permission_checker: self.permission_checker,
681        }
682    }
683}
684
685impl Default for BuiltinToolExecutorBuilder {
686    fn default() -> Self {
687        Self::new()
688    }
689}
690
691#[cfg(test)]
692mod tests {
693    use super::*;
694    use bamboo_agent_core::AgentEvent;
695    use bamboo_agent_core::FunctionCall;
696    use bamboo_agent_core::ToolCtx;
697    use bamboo_agent_core::ToolExecutionContext;
698    use bamboo_domain::tool_names::{normalize_tool_ref, BUILTIN_TOOL_NAMES};
699    use serde_json::json;
700    use std::sync::atomic::{AtomicUsize, Ordering};
701    use std::sync::Arc;
702    use tokio::fs;
703    use tokio::sync::mpsc;
704
705    use crate::tools::WriteTool;
706
707    fn make_tool_call(name: &str, args: serde_json::Value) -> ToolCall {
708        ToolCall {
709            id: "call_1".to_string(),
710            tool_type: "function".to_string(),
711            function: FunctionCall {
712                name: name.to_string(),
713                arguments: args.to_string(),
714            },
715        }
716    }
717
718    fn make_tool_call_with_raw_args(name: &str, raw_args: &str) -> ToolCall {
719        ToolCall {
720            id: "call_1".to_string(),
721            tool_type: "function".to_string(),
722            function: FunctionCall {
723                name: name.to_string(),
724                arguments: raw_args.to_string(),
725            },
726        }
727    }
728
729    fn make_executor(
730        permission_checker: Option<Arc<dyn PermissionChecker>>,
731    ) -> BuiltinToolExecutor {
732        let builder = BuiltinToolExecutorBuilder::new()
733            .with_tool(WriteTool::new())
734            .expect("register Write tool");
735
736        let builder = match permission_checker {
737            Some(checker) => builder.with_permission_checker(checker),
738            None => builder,
739        };
740
741        builder.build()
742    }
743
744    struct RecordingApprovalProxy {
745        requests: Arc<AtomicUsize>,
746        approve: bool,
747    }
748
749    #[async_trait]
750    impl crate::approval::ApprovalProxy for RecordingApprovalProxy {
751        async fn request_approval(&self, _ask: crate::approval::ApprovalAsk) -> bool {
752            self.requests.fetch_add(1, Ordering::SeqCst);
753            self.approve
754        }
755    }
756
757    #[test]
758    fn test_normalize_tool_ref_accepts_claude_style_names() {
759        assert_eq!(
760            normalize_tool_ref("default::Bash"),
761            Some("Bash".to_string())
762        );
763    }
764
765    #[test]
766    fn test_normalize_tool_ref_accepts_legacy_camel_aliases() {
767        assert_eq!(
768            normalize_tool_ref("default::fileExists"),
769            Some("FileExists".to_string())
770        );
771        assert_eq!(
772            normalize_tool_ref("default::getCurrentDir"),
773            Some("GetCurrentDir".to_string())
774        );
775        assert_eq!(
776            normalize_tool_ref("default::getFileInfo"),
777            Some("GetFileInfo".to_string())
778        );
779        assert_eq!(
780            normalize_tool_ref("default::setWorkspace"),
781            Some("SetWorkspace".to_string())
782        );
783        assert_eq!(
784            normalize_tool_ref("default::sleep"),
785            Some("Sleep".to_string())
786        );
787    }
788
789    #[test]
790    fn test_normalize_tool_ref_accepts_legacy_snake_case_aliases() {
791        assert_eq!(
792            normalize_tool_ref("default::execute_command"),
793            Some("Bash".to_string())
794        );
795        assert_eq!(
796            normalize_tool_ref("default::file_exists"),
797            Some("FileExists".to_string())
798        );
799        assert_eq!(
800            normalize_tool_ref("default::get_current_dir"),
801            Some("GetCurrentDir".to_string())
802        );
803        assert_eq!(
804            normalize_tool_ref("default::get_file_info"),
805            Some("GetFileInfo".to_string())
806        );
807        assert_eq!(
808            normalize_tool_ref("default::list_directory"),
809            Some("Glob".to_string())
810        );
811        assert_eq!(
812            normalize_tool_ref("default::memory_note"),
813            Some("memory_note".to_string())
814        );
815        assert_eq!(
816            normalize_tool_ref("default::read_file"),
817            Some("Read".to_string())
818        );
819        assert_eq!(
820            normalize_tool_ref("default::set_workspace"),
821            Some("SetWorkspace".to_string())
822        );
823        assert_eq!(
824            normalize_tool_ref("default::write_file"),
825            Some("Write".to_string())
826        );
827    }
828
829    #[test]
830    fn test_normalize_tool_ref_accepts_spawn_task_aliases() {
831        for alias in [
832            "default::spawn_session",
833            "default::sub_session",
834            "default::sub_task",
835            "default::team_agent",
836            "default::child_session",
837        ] {
838            assert_eq!(normalize_tool_ref(alias), Some("SubAgent".to_string()));
839        }
840    }
841
842    #[test]
843    fn test_normalize_tool_ref_accepts_server_overlay_tools() {
844        assert_eq!(normalize_tool_ref("compress_context"), None);
845        assert_eq!(
846            normalize_tool_ref("default::read_skill_resource"),
847            Some("read_skill_resource".to_string())
848        );
849    }
850
851    #[tokio::test]
852    async fn test_executor_accepts_legacy_read_file_path_argument() {
853        let dir = tempfile::tempdir().unwrap();
854        let file_path = dir.path().join("legacy-read.txt");
855        fs::write(&file_path, "legacy read content").await.unwrap();
856
857        let executor = BuiltinToolExecutor::new();
858        let call = make_tool_call("read_file", json!({"path": file_path}));
859
860        let result = executor.execute(&call).await.unwrap();
861        assert!(result.success);
862        assert!(result.result.contains("legacy read content"));
863    }
864
865    #[tokio::test]
866    async fn test_executor_accepts_legacy_list_directory_without_pattern() {
867        let dir = tempfile::tempdir().unwrap();
868        let file_path = dir.path().join("legacy-list.txt");
869        fs::write(&file_path, "legacy list content").await.unwrap();
870
871        let executor = BuiltinToolExecutor::new();
872        let call = make_tool_call("list_directory", json!({"path": dir.path()}));
873
874        let result = executor.execute(&call).await.unwrap();
875        assert!(result.success);
876        assert!(result.result.contains("legacy-list.txt"));
877    }
878
879    #[tokio::test]
880    async fn test_executor_accepts_canonical_read_with_path_argument() {
881        let dir = tempfile::tempdir().unwrap();
882        let file_path = dir.path().join("canonical-read.txt");
883        fs::write(&file_path, "canonical read content")
884            .await
885            .unwrap();
886
887        let executor = BuiltinToolExecutor::new();
888        let call = make_tool_call("Read", json!({"path": file_path}));
889
890        let result = executor.execute(&call).await.unwrap();
891        assert!(result.success);
892        assert!(result.result.contains("canonical read content"));
893    }
894
895    #[tokio::test]
896    async fn test_executor_accepts_canonical_glob_without_pattern_when_path_present() {
897        let dir = tempfile::tempdir().unwrap();
898        let file_path = dir.path().join("canonical-list.txt");
899        fs::write(&file_path, "canonical list content")
900            .await
901            .unwrap();
902
903        let executor = BuiltinToolExecutor::new();
904        let call = make_tool_call("Glob", json!({"path": dir.path()}));
905
906        let result = executor.execute(&call).await.unwrap();
907        assert!(result.success);
908        assert!(result.result.contains("canonical-list.txt"));
909    }
910
911    #[test]
912    fn test_executor_workspace_mutability_depends_on_path_argument() {
913        let executor = BuiltinToolExecutor::new();
914        let get_call = make_tool_call("Workspace", json!({}));
915        let set_call = make_tool_call("Workspace", json!({"path": "/tmp"}));
916
917        assert_eq!(
918            executor.call_mutability(&get_call),
919            crate::ToolMutability::ReadOnly
920        );
921        assert!(executor.call_concurrency_safe(&get_call));
922
923        assert_eq!(
924            executor.call_mutability(&set_call),
925            crate::ToolMutability::Mutating
926        );
927        assert!(!executor.call_concurrency_safe(&set_call));
928    }
929
930    #[test]
931    fn call_parallel_classification_matches_individual_methods() {
932        // Regression guard for the issue #17 perf refactor: the combined
933        // `call_parallel_classification` (which parses args once) must return the
934        // exact same (mutability, concurrency_safe) pair as calling
935        // `call_mutability` and `call_concurrency_safe` separately (which each
936        // parse args). Covers a read-only tool, mutating tools, and an
937        // args-aware tool (Workspace get vs set) so every branch of the
938        // single-parse override is exercised.
939        let executor = BuiltinToolExecutor::new();
940        let cases: &[(&str, serde_json::Value)] = &[
941            ("Read", json!({})),
942            ("Grep", json!({"pattern": "x"})),
943            (
944                "Write",
945                json!({"file_path": "/tmp/par_cls.txt", "content": "y"}),
946            ),
947            ("Bash", json!({"command": "echo hi"})),
948            ("Workspace", json!({})),
949            ("Workspace", json!({"path": "/tmp"})),
950        ];
951
952        for (name, args) in cases {
953            let call = make_tool_call(name, args.clone());
954            let expected_mutability = executor.call_mutability(&call);
955            let expected_concurrency = executor.call_concurrency_safe(&call);
956            let (mutability, concurrency) = executor.call_parallel_classification(&call);
957            assert_eq!(
958                mutability, expected_mutability,
959                "mutability mismatch for {name} ({args})"
960            );
961            assert_eq!(
962                concurrency, expected_concurrency,
963                "concurrency mismatch for {name} ({args})"
964            );
965        }
966    }
967
968    #[test]
969    fn list_tools_snapshot_is_stable_across_calls() {
970        // The per-round schema cache (issue #17 Part A) assumes the executor's
971        // `list_tools()` is stable within a round: a snapshot taken once must
972        // equal a fresh call. Guards that invariant so caching the set for the
973        // duration of a round can't serve a stale or filtered view.
974        let executor = BuiltinToolExecutor::new();
975        let first: Vec<String> = executor
976            .list_tools()
977            .into_iter()
978            .map(|s| s.function.name)
979            .collect();
980        let second: Vec<String> = executor
981            .list_tools()
982            .into_iter()
983            .map(|s| s.function.name)
984            .collect();
985        assert!(!first.is_empty(), "builtin executor should expose tools");
986        assert_eq!(
987            first, second,
988            "list_tools() must be deterministic per round"
989        );
990    }
991
992    #[tokio::test]
993    async fn test_executor_recovers_truncated_json_arguments() {
994        let dir = tempfile::tempdir().unwrap();
995        let path = dir.path().join("recovered-write.txt");
996
997        // Missing closing brace simulates EOF while parsing an object.
998        let malformed_args = format!(
999            r#"{{"file_path":"{}","content":"recovered content""#,
1000            path.display()
1001        );
1002
1003        let executor = BuiltinToolExecutor::new();
1004        let call = make_tool_call_with_raw_args("Write", &malformed_args);
1005
1006        let result = executor
1007            .execute(&call)
1008            .await
1009            .expect("truncated JSON should be auto-repaired");
1010        assert!(result.success);
1011
1012        let written = fs::read_to_string(&path)
1013            .await
1014            .expect("file should be written");
1015        assert_eq!(written, "recovered content");
1016    }
1017
1018    #[test]
1019    fn test_normalize_tool_ref_rejects_unknown_tool() {
1020        assert_eq!(normalize_tool_ref("default::search"), None);
1021    }
1022
1023    #[test]
1024    fn test_executor_does_not_expose_legacy_tools() {
1025        let executor = BuiltinToolExecutor::new();
1026        let tool_names: Vec<String> = executor
1027            .list_tools()
1028            .into_iter()
1029            .map(|schema| schema.function.name)
1030            .collect();
1031
1032        for legacy in ["claude_code", "search_in_file", "search_in_project"] {
1033            assert!(!tool_names.iter().any(|name| name == legacy));
1034        }
1035    }
1036
1037    #[test]
1038    fn test_critical_tool_schemas_match_claude_shapes() {
1039        let executor = BuiltinToolExecutor::new();
1040        let tools = executor.list_tools();
1041
1042        let get_params = |name: &str| {
1043            tools
1044                .iter()
1045                .find(|tool| tool.function.name == name)
1046                .unwrap()
1047                .function
1048                .parameters
1049                .clone()
1050        };
1051
1052        let grep = get_params("Grep");
1053        assert_eq!(grep["required"], json!(["pattern"]));
1054        assert_eq!(
1055            grep["properties"]["output_mode"]["enum"],
1056            json!(["content", "files_with_matches", "count"])
1057        );
1058        assert!(grep["properties"]["-A"].is_object());
1059        assert!(grep["properties"]["-B"].is_object());
1060        assert!(grep["properties"]["-C"].is_object());
1061        assert!(grep["properties"]["-n"].is_object());
1062        assert!(grep["properties"]["-i"].is_object());
1063
1064        let edit = get_params("Edit");
1065        assert_eq!(edit["required"], json!(["file_path"]));
1066        assert_eq!(edit["properties"]["old_string"]["type"], "string");
1067        assert_eq!(edit["properties"]["new_string"]["type"], "string");
1068        assert_eq!(edit["properties"]["patch"]["type"], "string");
1069        assert_eq!(edit["properties"]["replace_all"]["type"], "boolean");
1070        assert!(edit.get("oneOf").is_none());
1071
1072        // apply_patch is now an alias for Edit – its schema is the Edit
1073        // schema, so we just verify that Edit includes the patch property.
1074        assert_eq!(edit["properties"]["patch"]["type"], "string");
1075        assert_eq!(edit["properties"]["line_number"]["type"], "integer");
1076
1077        let bash = get_params("Bash");
1078        assert_eq!(bash["required"], json!(["command"]));
1079        assert_eq!(bash["properties"]["run_in_background"]["type"], "boolean");
1080        assert_eq!(bash["properties"]["workdir"]["type"], "string");
1081
1082        let bash_output = get_params("BashOutput");
1083        assert_eq!(bash_output["required"], json!(["bash_id"]));
1084        assert_eq!(bash_output["properties"]["filter"]["type"], "string");
1085    }
1086
1087    #[test]
1088    fn test_tool_schemas_avoid_openai_forbidden_top_level_keywords() {
1089        let executor = BuiltinToolExecutor::new();
1090        let tools = executor.list_tools();
1091        let forbidden = ["oneOf", "anyOf", "allOf", "not", "enum"];
1092
1093        for tool in tools {
1094            let params = &tool.function.parameters;
1095            assert_eq!(
1096                params["type"], "object",
1097                "tool '{}' parameters must be a top-level object schema",
1098                tool.function.name
1099            );
1100            for key in forbidden {
1101                assert!(
1102                    params.get(key).is_none(),
1103                    "tool '{}' parameters contains forbidden top-level keyword '{}'",
1104                    tool.function.name,
1105                    key
1106                );
1107            }
1108        }
1109    }
1110
1111    #[test]
1112    fn test_executor_has_all_builtin_tools() {
1113        let executor = BuiltinToolExecutor::new();
1114        let tools = executor.list_tools();
1115
1116        assert_eq!(tools.len(), BUILTIN_TOOL_NAMES.len());
1117
1118        let tool_names: Vec<String> = tools.iter().map(|t| t.function.name.clone()).collect();
1119        for tool_name in BUILTIN_TOOL_NAMES {
1120            assert!(tool_names.contains(&tool_name.to_string()));
1121        }
1122    }
1123
1124    #[test]
1125    fn test_executor_builds_enhanced_prompt() {
1126        let executor = BuiltinToolExecutor::new();
1127        let prompt = executor.build_enhanced_prompt(GuideBuildContext::default());
1128        assert!(prompt.contains("## Tool Usage Guidelines"));
1129        assert!(prompt.contains("**Read**"));
1130    }
1131
1132    #[test]
1133    fn test_executor_builder_empty() {
1134        let executor = BuiltinToolExecutorBuilder::new().build();
1135        assert!(executor.list_tools().is_empty());
1136    }
1137
1138    #[test]
1139    fn test_executor_builder_with_default_tools() {
1140        let executor = BuiltinToolExecutorBuilder::new()
1141            .with_default_tools()
1142            .build();
1143        assert_eq!(executor.list_tools().len(), BUILTIN_TOOL_NAMES.len());
1144    }
1145
1146    #[test]
1147    fn test_executor_builder_with_specific_tool() {
1148        let executor = BuiltinToolExecutorBuilder::new()
1149            .with_filesystem_tool("Read")
1150            .unwrap()
1151            .build();
1152
1153        let tools = executor.list_tools();
1154        assert_eq!(tools.len(), 1);
1155        assert_eq!(tools[0].function.name, "Read");
1156    }
1157
1158    #[tokio::test]
1159    async fn test_executor_skips_permission_checks_without_checker() {
1160        let executor = make_executor(None);
1161        let path = "/tmp/executor_permission_none.txt";
1162        let _ = fs::remove_file(path).await;
1163
1164        let call = make_tool_call("Write", json!({"file_path": path, "content": "ok"}));
1165        let result = executor.execute(&call).await.expect("execute tool");
1166
1167        assert!(result.success);
1168        let _ = fs::remove_file(path).await;
1169    }
1170
1171    #[tokio::test]
1172    async fn test_executor_with_permission_checker_enforces_checks() {
1173        let checker = Arc::new(crate::permission::DenyDangerousPermissionChecker);
1174        let executor = make_executor(Some(checker));
1175        let path = "/tmp/executor_permission_denied.txt";
1176        let _ = fs::remove_file(path).await;
1177
1178        let call = make_tool_call("Write", json!({"file_path": path, "content": "nope"}));
1179        let result = executor.execute(&call).await;
1180
1181        assert!(matches!(result, Err(ToolError::Execution(_))));
1182        assert!(fs::metadata(path).await.is_err());
1183    }
1184
1185    #[tokio::test]
1186    async fn test_bypass_permissions_skips_checker() {
1187        // Model the worker side of a child whose parent bypass flag was inherited:
1188        // a production Bash tool under the production config evaluator must
1189        // execute an ordinary command directly. Even though both a parent
1190        // approval proxy and a human-event sink are installed, neither path may
1191        // be touched.
1192        let config = Arc::new(crate::permission::PermissionConfig::new());
1193        let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
1194        let executor = BuiltinToolExecutorBuilder::new()
1195            .with_tool(BashTool::new())
1196            .expect("register Bash tool")
1197            .with_permission_checker(checker)
1198            .build();
1199        let dir = tempfile::tempdir().unwrap();
1200        let path = dir.path().join("bypass_allows_bash.txt");
1201        let command = format!("printf ordinary > {}", path.display());
1202        let approval_requests = Arc::new(AtomicUsize::new(0));
1203        let proxy: Arc<dyn crate::approval::ApprovalProxy> = Arc::new(RecordingApprovalProxy {
1204            requests: approval_requests.clone(),
1205            approve: true,
1206        });
1207        let (event_tx, mut event_rx) = mpsc::channel(8);
1208
1209        let call = make_tool_call("Bash", json!({"command": command}));
1210        let ctx = ToolExecutionContext {
1211            session_id: Some("s-bypass"),
1212            tool_call_id: &call.id,
1213            event_tx: Some(&event_tx),
1214            available_tool_schemas: None,
1215            bypass_permissions: true,
1216            can_async_resume: false,
1217            bash_completion_sink: None,
1218            pre_parsed_args: None,
1219        };
1220        let result = crate::approval::with_approval_proxy(
1221            Some(proxy),
1222            executor.execute_with_context(&call, ctx),
1223        )
1224        .await;
1225
1226        assert!(result.is_ok(), "bypass should allow the write: {result:?}");
1227        assert_eq!(fs::read_to_string(&path).await.unwrap(), "ordinary");
1228        assert_eq!(
1229            approval_requests.load(Ordering::SeqCst),
1230            0,
1231            "ordinary bypassed child command must not invoke the parent reviewer"
1232        );
1233        assert!(
1234            event_rx.try_recv().is_err(),
1235            "ordinary bypassed child command must not emit a human approval event"
1236        );
1237    }
1238
1239    #[tokio::test]
1240    async fn hook_allow_skips_configured_ask_for_exact_call() {
1241        let dir = tempfile::tempdir().unwrap();
1242        let path = dir.path().join("hook-allowed.txt");
1243        let path_str = path.to_str().unwrap().to_string();
1244        let config = Arc::new(crate::permission::PermissionConfig::new());
1245        config.set_ask_rules([format!("Write({}/**)", dir.path().to_str().unwrap())]);
1246        let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
1247        let executor = make_executor(Some(checker));
1248        let call = make_tool_call(
1249            "Write",
1250            json!({"file_path": path_str, "content": "allowed by hook"}),
1251        );
1252        let ctx = ToolExecutionContext {
1253            session_id: Some("s-hook-allow"),
1254            tool_call_id: &call.id,
1255            event_tx: None,
1256            available_tool_schemas: None,
1257            bypass_permissions: false,
1258            can_async_resume: false,
1259            bash_completion_sink: None,
1260            pre_parsed_args: None,
1261        };
1262
1263        let result = crate::with_hook_permission_override(
1264            Some(crate::HookPermissionOverride::Allow),
1265            &call.id,
1266            executor.execute_with_context(&call, ctx),
1267        )
1268        .await;
1269
1270        assert!(
1271            result.is_ok(),
1272            "hook allow should skip ordinary ask: {result:?}"
1273        );
1274        assert_eq!(fs::read_to_string(path).await.unwrap(), "allowed by hook");
1275        assert_eq!(
1276            crate::current_hook_permission_override(&call.id),
1277            None,
1278            "the one-call override must not leak"
1279        );
1280    }
1281
1282    #[tokio::test]
1283    async fn hook_allow_cannot_skip_hard_dangerous_parent_review() {
1284        let config = Arc::new(crate::permission::PermissionConfig::new());
1285        let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
1286        let executor = BuiltinToolExecutorBuilder::new()
1287            .with_tool(BashTool::new())
1288            .expect("register Bash tool")
1289            .with_permission_checker(checker)
1290            .build();
1291        let dir = tempfile::tempdir().unwrap();
1292        let path = dir.path().join("hard-dangerous-must-not-run.txt");
1293        let command = format!("eval 'printf denied > {}'", path.display());
1294        let requests = Arc::new(AtomicUsize::new(0));
1295        let proxy: Arc<dyn crate::approval::ApprovalProxy> = Arc::new(RecordingApprovalProxy {
1296            requests: requests.clone(),
1297            approve: false,
1298        });
1299        let call = make_tool_call("Bash", json!({"command": command}));
1300        let ctx = ToolExecutionContext {
1301            session_id: Some("s-hook-hard-dangerous"),
1302            tool_call_id: &call.id,
1303            event_tx: None,
1304            available_tool_schemas: None,
1305            bypass_permissions: true,
1306            can_async_resume: false,
1307            bash_completion_sink: None,
1308            pre_parsed_args: None,
1309        };
1310
1311        let result = crate::with_hook_permission_override(
1312            Some(crate::HookPermissionOverride::Allow),
1313            &call.id,
1314            crate::approval::with_approval_proxy(
1315                Some(proxy),
1316                executor.execute_with_context(&call, ctx),
1317            ),
1318        )
1319        .await;
1320
1321        assert!(
1322            matches!(result, Err(ToolError::Execution(ref message)) if message.contains("denied by host")),
1323            "hard-dangerous review must remain authoritative: {result:?}"
1324        );
1325        assert_eq!(requests.load(Ordering::SeqCst), 1);
1326        assert!(!path.exists());
1327    }
1328
1329    #[tokio::test]
1330    async fn hook_allow_cannot_skip_explicit_deny() {
1331        let dir = tempfile::tempdir().unwrap();
1332        let path = dir.path().join("explicit-deny.txt");
1333        let path_str = path.to_str().unwrap().to_string();
1334        let config = Arc::new(crate::permission::PermissionConfig::new());
1335        config.deny_scoped_session_permission(
1336            "s-hook-explicit-deny",
1337            crate::permission::PermissionType::WriteFile,
1338            path_str.clone(),
1339        );
1340        let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
1341        let executor = make_executor(Some(checker));
1342        let call = make_tool_call(
1343            "Write",
1344            json!({"file_path": path_str, "content": "must not be written"}),
1345        );
1346        let ctx = ToolExecutionContext {
1347            session_id: Some("s-hook-explicit-deny"),
1348            tool_call_id: &call.id,
1349            event_tx: None,
1350            available_tool_schemas: None,
1351            bypass_permissions: false,
1352            can_async_resume: false,
1353            bash_completion_sink: None,
1354            pre_parsed_args: None,
1355        };
1356
1357        let result = crate::with_hook_permission_override(
1358            Some(crate::HookPermissionOverride::Allow),
1359            &call.id,
1360            executor.execute_with_context(&call, ctx),
1361        )
1362        .await;
1363
1364        assert!(
1365            matches!(result, Err(ToolError::Execution(ref message)) if message.contains("remembered session decision")),
1366            "explicit deny must remain authoritative: {result:?}"
1367        );
1368        assert!(!path.exists());
1369    }
1370
1371    #[tokio::test]
1372    async fn test_forced_ask_rule_overrides_bypass() {
1373        // A hard-dangerous Bash command must still traverse the worker's parent
1374        // approval proxy under bypass. The returned verdict is authoritative:
1375        // deny prevents execution, while approve lets the exact command run.
1376        let config = Arc::new(crate::permission::PermissionConfig::new());
1377        let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
1378        let executor = BuiltinToolExecutorBuilder::new()
1379            .with_tool(BashTool::new())
1380            .expect("register Bash tool")
1381            .with_permission_checker(checker)
1382            .build();
1383        let dir = tempfile::tempdir().unwrap();
1384        let denied_path = dir.path().join("forced-denied.txt");
1385        let denied_command = format!("eval 'printf denied > {}'", denied_path.display());
1386        let denied_requests = Arc::new(AtomicUsize::new(0));
1387        let deny_proxy: Arc<dyn crate::approval::ApprovalProxy> =
1388            Arc::new(RecordingApprovalProxy {
1389                requests: denied_requests.clone(),
1390                approve: false,
1391            });
1392
1393        let denied_call = make_tool_call("Bash", json!({"command": denied_command}));
1394        let denied_ctx = ToolExecutionContext {
1395            session_id: Some("s-forced"),
1396            tool_call_id: &denied_call.id,
1397            event_tx: None,
1398            available_tool_schemas: None,
1399            bypass_permissions: true,
1400            can_async_resume: false,
1401            bash_completion_sink: None,
1402            pre_parsed_args: None,
1403        };
1404        let denied = crate::approval::with_approval_proxy(
1405            Some(deny_proxy),
1406            executor.execute_with_context(&denied_call, denied_ctx),
1407        )
1408        .await;
1409
1410        assert!(
1411            matches!(denied, Err(ToolError::Execution(ref message)) if message.contains("denied by host")),
1412            "parent denial must block forced-ask execution under bypass: {denied:?}"
1413        );
1414        assert_eq!(denied_requests.load(Ordering::SeqCst), 1);
1415        assert!(!denied_path.exists(), "denied command must not execute");
1416
1417        let approved_path = dir.path().join("forced-approved.txt");
1418        let approved_command = format!("eval 'printf approved > {}'", approved_path.display());
1419        let approved_requests = Arc::new(AtomicUsize::new(0));
1420        let approve_proxy: Arc<dyn crate::approval::ApprovalProxy> =
1421            Arc::new(RecordingApprovalProxy {
1422                requests: approved_requests.clone(),
1423                approve: true,
1424            });
1425        let approved_call = make_tool_call("Bash", json!({"command": approved_command}));
1426        let approved_ctx = ToolExecutionContext {
1427            session_id: Some("s-forced"),
1428            tool_call_id: &approved_call.id,
1429            event_tx: None,
1430            available_tool_schemas: None,
1431            bypass_permissions: true,
1432            can_async_resume: false,
1433            bash_completion_sink: None,
1434            pre_parsed_args: None,
1435        };
1436        let approved = crate::approval::with_approval_proxy(
1437            Some(approve_proxy),
1438            executor.execute_with_context(&approved_call, approved_ctx),
1439        )
1440        .await;
1441
1442        assert!(
1443            approved.is_ok(),
1444            "parent approval must allow forced-ask execution under bypass: {approved:?}"
1445        );
1446        assert_eq!(approved_requests.load(Ordering::SeqCst), 1);
1447        assert_eq!(fs::read_to_string(approved_path).await.unwrap(), "approved");
1448    }
1449
1450    #[tokio::test]
1451    async fn test_explicit_deny_overrides_bypass() {
1452        let dir = tempfile::tempdir().unwrap();
1453        let path = dir.path().join("explicit-deny.txt");
1454        let path_str = path.to_str().unwrap();
1455        let config = Arc::new(crate::permission::PermissionConfig::new());
1456        config.add_rule(crate::permission::PermissionRule::new(
1457            crate::permission::PermissionType::WriteFile,
1458            path_str,
1459            false,
1460        ));
1461        let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
1462        let executor = make_executor(Some(checker));
1463        let call = make_tool_call(
1464            "Write",
1465            json!({"file_path": path_str, "content": "blocked"}),
1466        );
1467        let ctx = ToolExecutionContext {
1468            session_id: Some("s-explicit-deny"),
1469            tool_call_id: &call.id,
1470            event_tx: None,
1471            available_tool_schemas: None,
1472            bypass_permissions: true,
1473            can_async_resume: false,
1474            bash_completion_sink: None,
1475            pre_parsed_args: None,
1476        };
1477
1478        let result = executor.execute_with_context(&call, ctx).await;
1479        assert!(
1480            matches!(result, Err(ToolError::Execution(ref message)) if message.contains("explicit policy")),
1481            "explicit deny must beat bypass: {result:?}"
1482        );
1483        assert!(!path.exists());
1484    }
1485
1486    #[tokio::test]
1487    async fn test_explicit_delete_deny_overrides_bypass() {
1488        let config = Arc::new(crate::permission::PermissionConfig::new());
1489        config.add_rule(crate::permission::PermissionRule::new(
1490            crate::permission::PermissionType::DeleteOperation,
1491            "rm child-to-preserve",
1492            false,
1493        ));
1494        let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
1495        let executor = BuiltinToolExecutorBuilder::new()
1496            .with_tool(BashTool::new())
1497            .expect("register Bash tool")
1498            .with_permission_checker(checker)
1499            .build();
1500        let call = make_tool_call("Bash", json!({"command": "rm child-to-preserve"}));
1501        let ctx = ToolExecutionContext {
1502            session_id: Some("s-explicit-delete-deny"),
1503            tool_call_id: &call.id,
1504            event_tx: None,
1505            available_tool_schemas: None,
1506            bypass_permissions: true,
1507            can_async_resume: false,
1508            bash_completion_sink: None,
1509            pre_parsed_args: None,
1510        };
1511
1512        let result = executor.execute_with_context(&call, ctx).await;
1513        assert!(
1514            matches!(result, Err(ToolError::Execution(ref message)) if message.contains("explicit policy")),
1515            "explicit delete deny must beat bypass: {result:?}"
1516        );
1517    }
1518
1519    #[tokio::test]
1520    async fn interactive_gate_returns_synthesized_approval_pause() {
1521        // With an event sink present, a forced-ask rule that yields
1522        // `ConfirmationRequired` must resolve to the synthesized "awaiting
1523        // approval" PAUSE result (a `Completed` result tagged
1524        // `display_preference = "request_permissions"`) — NOT an error — so the
1525        // engine turns it into a clarification pause. This locks in the
1526        // interactive-sink path that the `check_permissions_for` extraction must
1527        // preserve as `Ok(Some(outcome))` rather than collapse to an `Err`.
1528        let config = Arc::new(crate::permission::PermissionConfig::new());
1529        config.set_ask_rules(["Write(/etc/**)".to_string()]);
1530        config.register_session_workspace("s-interactive", "/workspace/project");
1531        let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
1532        let executor = make_executor(Some(checker));
1533
1534        let (tx, mut rx) = mpsc::channel(8);
1535        let call = make_tool_call(
1536            "Write",
1537            json!({"file_path": "/etc/gated.conf", "content": "x"}),
1538        );
1539        let ctx = ToolExecutionContext {
1540            session_id: Some("s-interactive"),
1541            tool_call_id: &call.id,
1542            event_tx: Some(&tx),
1543            available_tool_schemas: None,
1544            bypass_permissions: false,
1545            can_async_resume: false,
1546            bash_completion_sink: None,
1547            pre_parsed_args: None,
1548        };
1549
1550        let result = executor
1551            .execute_with_context(&call, ctx)
1552            .await
1553            .expect("interactive gate should pause (Ok), not error");
1554
1555        assert_eq!(
1556            result.display_preference.as_deref(),
1557            Some("request_permissions"),
1558            "interactive gate must return the request_permissions pause result"
1559        );
1560        assert!(result.result.contains("awaiting_permission_approval"));
1561        let payload: serde_json::Value = serde_json::from_str(&result.result).expect("payload");
1562        let request = &payload["permission_request"];
1563        assert_eq!(request["request_id"], call.id);
1564        assert_eq!(request["session_id"], "s-interactive");
1565        assert_eq!(request["workspace_path"], "/workspace/project");
1566        assert_eq!(request["reason_code"], "configured_always_ask");
1567        assert_eq!(
1568            request["allowed_decisions"],
1569            json!(["allow_once", "deny_once"])
1570        );
1571        assert_eq!(payload["options"], json!(["Approve", "Deny"]));
1572        assert!(fs::metadata("/etc/gated.conf").await.is_err());
1573
1574        let ev = rx.recv().await.expect("approval event should be emitted");
1575        assert!(
1576            matches!(ev, AgentEvent::ToolApprovalRequested { tool_name, .. } if tool_name == "Write")
1577        );
1578    }
1579
1580    #[tokio::test]
1581    async fn check_permissions_for_returns_none_when_permitted() {
1582        // A tool with no matching gate (Read, no checker rule) passes the gate:
1583        // `check_permissions_for` returns `Ok(None)` so the caller runs the tool.
1584        let executor = make_executor(None);
1585        let call = make_tool_call("Read", json!({"file_path": "/tmp/whatever"}));
1586        let ctx = ToolExecutionContext::none(&call.id);
1587        let decision = executor
1588            .check_permissions_for(&call, &ctx)
1589            .await
1590            .expect("no checker means no gate");
1591        assert!(decision.is_none(), "no checker must yield Ok(None)");
1592    }
1593
1594    // ---- Phase 2: cross-process approval proxy ----------------------------
1595
1596    struct HostStub {
1597        approve: bool,
1598    }
1599
1600    #[async_trait]
1601    impl crate::approval::ApprovalProxy for HostStub {
1602        async fn request_approval(&self, _ask: crate::approval::ApprovalAsk) -> bool {
1603            self.approve
1604        }
1605    }
1606
1607    #[tokio::test]
1608    async fn approval_proxy_grant_lets_gated_tool_proceed() {
1609        // A subagent worker installs an ApprovalProxy for its run. A forced-ask
1610        // rule with NO event sink would otherwise fail closed; with the host
1611        // proxy granting, the executor treats the context as approved and the
1612        // tool proceeds inline (no suspend, no synthetic pause).
1613        let dir = tempfile::tempdir().unwrap();
1614        let path = dir.path().join("approved.txt");
1615        let path_str = path.to_str().unwrap().to_string();
1616        let config = Arc::new(crate::permission::PermissionConfig::new());
1617        config.set_ask_rules([format!("Write({}/**)", dir.path().to_str().unwrap())]);
1618        let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
1619        let executor = make_executor(Some(checker));
1620
1621        let call = make_tool_call("Write", json!({"file_path": path_str, "content": "ok"}));
1622        let ctx = ToolExecutionContext {
1623            session_id: Some("s-worker"),
1624            tool_call_id: &call.id,
1625            event_tx: None,
1626            available_tool_schemas: None,
1627            bypass_permissions: false,
1628            can_async_resume: false,
1629            bash_completion_sink: None,
1630            pre_parsed_args: None,
1631        };
1632
1633        let proxy: Arc<dyn crate::approval::ApprovalProxy> = Arc::new(HostStub { approve: true });
1634        let result = crate::approval::with_approval_proxy(
1635            Some(proxy),
1636            executor.execute_with_context(&call, ctx),
1637        )
1638        .await;
1639
1640        assert!(
1641            result.is_ok(),
1642            "host grant should let the write through: {result:?}"
1643        );
1644        assert_eq!(fs::read_to_string(&path).await.unwrap(), "ok");
1645    }
1646
1647    #[tokio::test]
1648    async fn approval_proxy_deny_fails_gated_tool_closed() {
1649        // With the host proxy denying, the gated tool fails closed and the side
1650        // effect never happens.
1651        let dir = tempfile::tempdir().unwrap();
1652        let path = dir.path().join("denied.txt");
1653        let path_str = path.to_str().unwrap().to_string();
1654        let config = Arc::new(crate::permission::PermissionConfig::new());
1655        config.set_ask_rules([format!("Write({}/**)", dir.path().to_str().unwrap())]);
1656        let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
1657        let executor = make_executor(Some(checker));
1658
1659        let call = make_tool_call("Write", json!({"file_path": path_str, "content": "nope"}));
1660        let ctx = ToolExecutionContext {
1661            session_id: Some("s-worker"),
1662            tool_call_id: &call.id,
1663            event_tx: None,
1664            available_tool_schemas: None,
1665            bypass_permissions: false,
1666            can_async_resume: false,
1667            bash_completion_sink: None,
1668            pre_parsed_args: None,
1669        };
1670
1671        let proxy: Arc<dyn crate::approval::ApprovalProxy> = Arc::new(HostStub { approve: false });
1672        let result = crate::approval::with_approval_proxy(
1673            Some(proxy),
1674            executor.execute_with_context(&call, ctx),
1675        )
1676        .await;
1677
1678        assert!(
1679            matches!(result, Err(ToolError::Execution(ref m)) if m.contains("denied by host")),
1680            "host deny should fail the tool closed: {result:?}"
1681        );
1682        assert!(fs::metadata(&path).await.is_err());
1683    }
1684
1685    #[tokio::test]
1686    async fn tool_can_stream_events_via_execute_with_context() {
1687        struct StreamingTool;
1688
1689        #[async_trait]
1690        impl Tool for StreamingTool {
1691            fn name(&self) -> &str {
1692                "streaming_tool"
1693            }
1694
1695            fn description(&self) -> &str {
1696                "streams one token"
1697            }
1698
1699            fn parameters_schema(&self) -> serde_json::Value {
1700                json!({"type":"object","properties":{}})
1701            }
1702
1703            async fn invoke(
1704                &self,
1705                _args: serde_json::Value,
1706                ctx: ToolCtx,
1707            ) -> Result<ToolOutcome, ToolError> {
1708                ctx.emit(AgentEvent::Token {
1709                    content: "stream".to_string(),
1710                })
1711                .await;
1712                Ok(ToolOutcome::Completed(ToolResult {
1713                    success: true,
1714                    result: "ok".to_string(),
1715                    display_preference: None,
1716                    images: Vec::new(),
1717                }))
1718            }
1719        }
1720
1721        let executor = BuiltinToolExecutor::new();
1722        executor
1723            .register_tool(StreamingTool)
1724            .expect("register streaming tool");
1725
1726        let (tx, mut rx) = mpsc::channel(8);
1727        let call = make_tool_call("streaming_tool", json!({}));
1728
1729        let result = executor
1730            .execute_with_context(
1731                &call,
1732                ToolExecutionContext {
1733                    session_id: Some("s1"),
1734                    tool_call_id: &call.id,
1735                    event_tx: Some(&tx),
1736                    available_tool_schemas: None,
1737                    bypass_permissions: false,
1738                    can_async_resume: false,
1739                    bash_completion_sink: None,
1740                    pre_parsed_args: None,
1741                },
1742            )
1743            .await
1744            .expect("execute tool");
1745
1746        assert!(result.success);
1747        assert_eq!(result.result, "ok");
1748
1749        let ev = rx.recv().await.expect("expected streamed event");
1750        assert!(
1751            matches!(ev, AgentEvent::ToolToken { tool_call_id, content } if tool_call_id == "call_1" && content == "stream")
1752        );
1753    }
1754
1755    #[tokio::test]
1756    async fn removed_legacy_tools_return_not_found() {
1757        let executor = BuiltinToolExecutor::new();
1758
1759        for legacy in ["claude_code", "search_in_file", "search_in_project"] {
1760            let call = make_tool_call(legacy, json!({}));
1761            let result = executor.execute(&call).await;
1762            assert!(matches!(result, Err(ToolError::NotFound(_))));
1763        }
1764    }
1765
1766    #[tokio::test]
1767    async fn executor_prefers_exact_tool_name_before_builtin_alias() {
1768        struct CustomSpawnSessionTool;
1769
1770        #[async_trait]
1771        impl Tool for CustomSpawnSessionTool {
1772            fn name(&self) -> &str {
1773                "spawn_session"
1774            }
1775
1776            fn description(&self) -> &str {
1777                "custom tool for regression coverage"
1778            }
1779
1780            fn parameters_schema(&self) -> serde_json::Value {
1781                json!({"type":"object","properties":{}})
1782            }
1783
1784            async fn invoke(
1785                &self,
1786                _args: serde_json::Value,
1787                _ctx: ToolCtx,
1788            ) -> Result<ToolOutcome, ToolError> {
1789                Ok(ToolOutcome::Completed(ToolResult {
1790                    success: true,
1791                    result: "custom-spawn-session".to_string(),
1792                    display_preference: None,
1793                    images: Vec::new(),
1794                }))
1795            }
1796        }
1797
1798        let executor = BuiltinToolExecutorBuilder::new()
1799            .with_tool(CustomSpawnSessionTool)
1800            .expect("register custom spawn_session tool")
1801            .build();
1802
1803        let call = make_tool_call("spawn_session", json!({}));
1804        let result = executor.execute(&call).await.expect("execute custom tool");
1805        assert!(result.success);
1806        assert_eq!(result.result, "custom-spawn-session");
1807    }
1808
1809    // ---- issue #106: parse tool args once on the execute path -------------
1810
1811    /// A tool that echoes back the `v` field of the args it was invoked with, so
1812    /// a test can observe *which* parsed value reached the tool.
1813    struct EchoArgsTool;
1814
1815    #[async_trait]
1816    impl Tool for EchoArgsTool {
1817        fn name(&self) -> &str {
1818            "echo_args"
1819        }
1820        fn description(&self) -> &str {
1821            "echoes the `v` arg"
1822        }
1823        fn parameters_schema(&self) -> serde_json::Value {
1824            json!({"type":"object","properties":{"v":{"type":"string"}}})
1825        }
1826        async fn invoke(
1827            &self,
1828            args: serde_json::Value,
1829            _ctx: ToolCtx,
1830        ) -> Result<ToolOutcome, ToolError> {
1831            let v = args
1832                .get("v")
1833                .and_then(serde_json::Value::as_str)
1834                .unwrap_or("<none>")
1835                .to_string();
1836            Ok(ToolOutcome::Completed(ToolResult {
1837                success: true,
1838                result: v,
1839                display_preference: None,
1840                images: Vec::new(),
1841            }))
1842        }
1843    }
1844
1845    fn ctx_with_pre_parsed<'a>(
1846        call_id: &'a str,
1847        pre_parsed: Option<&'a serde_json::Value>,
1848    ) -> ToolExecutionContext<'a> {
1849        ToolExecutionContext {
1850            session_id: Some("s-106"),
1851            tool_call_id: call_id,
1852            event_tx: None,
1853            available_tool_schemas: None,
1854            bypass_permissions: false,
1855            can_async_resume: false,
1856            bash_completion_sink: None,
1857            pre_parsed_args: pre_parsed,
1858        }
1859    }
1860
1861    #[tokio::test]
1862    async fn execute_with_context_reuses_pre_parsed_args_without_reparsing() {
1863        // The raw `arguments` string and the threaded `pre_parsed_args` Value
1864        // deliberately disagree. If the executor honored the contract (parse
1865        // once at the dispatch site, reuse downstream), the tool sees the
1866        // pre-parsed value; if it re-parsed the raw string it would see "raw".
1867        // This is the load-bearing proof that the second parse was eliminated.
1868        let executor = BuiltinToolExecutor::new();
1869        executor.register_tool(EchoArgsTool).expect("register echo");
1870
1871        let call = make_tool_call("echo_args", json!({"v": "raw"}));
1872        let pre_parsed = json!({"v": "preparsed"});
1873        let ctx = ctx_with_pre_parsed(&call.id, Some(&pre_parsed));
1874
1875        let result = executor
1876            .execute_with_context(&call, ctx)
1877            .await
1878            .expect("execute echo tool");
1879        assert_eq!(
1880            result.result, "preparsed",
1881            "executor must reuse pre_parsed_args, not re-parse the raw string"
1882        );
1883    }
1884
1885    #[tokio::test]
1886    async fn execute_with_context_parses_raw_when_no_pre_parsed_args() {
1887        // Without a threaded value (the `execute` entry point / tests / a loop
1888        // that parsed with a different parser), the executor falls back to
1889        // parsing the raw string exactly as before — behavior preserved.
1890        let executor = BuiltinToolExecutor::new();
1891        executor.register_tool(EchoArgsTool).expect("register echo");
1892
1893        let call = make_tool_call("echo_args", json!({"v": "raw"}));
1894        let ctx = ctx_with_pre_parsed(&call.id, None);
1895
1896        let result = executor
1897            .execute_with_context(&call, ctx)
1898            .await
1899            .expect("execute echo tool");
1900        assert_eq!(
1901            result.result, "raw",
1902            "without pre_parsed_args the executor parses the raw string as before"
1903        );
1904    }
1905
1906    #[tokio::test]
1907    async fn execute_with_context_malformed_args_repair_unchanged_without_pre_parsed() {
1908        // Malformed (truncated) JSON must still be auto-repaired by the
1909        // fallback parse when no pre-parsed value is threaded — the existing
1910        // error/leniency behavior is untouched by the dedup.
1911        let dir = tempfile::tempdir().unwrap();
1912        let path = dir.path().join("recovered-no-preparsed.txt");
1913        let malformed_args = format!(
1914            r#"{{"file_path":"{}","content":"recovered content""#,
1915            path.display()
1916        );
1917
1918        let executor = BuiltinToolExecutor::new();
1919        let call = make_tool_call_with_raw_args("Write", &malformed_args);
1920        let ctx = ctx_with_pre_parsed(&call.id, None);
1921
1922        let result = executor
1923            .execute_with_context(&call, ctx)
1924            .await
1925            .expect("truncated JSON should be auto-repaired");
1926        assert!(result.success);
1927        let written = fs::read_to_string(&path).await.expect("file written");
1928        assert_eq!(written, "recovered content");
1929    }
1930}