Skip to main content

bamboo_server_tools/
overlay_executor.rs

1use async_trait::async_trait;
2
3use bamboo_agent_core::tools::{
4    normalize_tool_name, parse_tool_args_best_effort, Tool, ToolCall, ToolError,
5    ToolExecutionContext, ToolExecutor, ToolOutcome, ToolResult, ToolSchema,
6};
7use bamboo_tools::normalize_tool_ref;
8
9/// Tool executor that overlays a single tool on top of an existing executor.
10///
11/// This is used to add server-only tools (like `SubAgent`) without mutating the
12/// underlying built-in/MCP executor.
13pub struct OverlayToolExecutor {
14    base: std::sync::Arc<dyn ToolExecutor>,
15    overlay: std::sync::Arc<dyn Tool>,
16}
17
18impl OverlayToolExecutor {
19    pub fn new(base: std::sync::Arc<dyn ToolExecutor>, overlay: std::sync::Arc<dyn Tool>) -> Self {
20        Self { base, overlay }
21    }
22}
23
24#[async_trait]
25impl ToolExecutor for OverlayToolExecutor {
26    async fn execute(&self, call: &ToolCall) -> Result<ToolResult, ToolError> {
27        self.execute_with_context(call, ToolExecutionContext::none(&call.id))
28            .await
29    }
30
31    async fn execute_with_context(
32        &self,
33        call: &ToolCall,
34        ctx: ToolExecutionContext<'_>,
35    ) -> Result<ToolResult, ToolError> {
36        let name = normalize_tool_name(&call.function.name);
37        let is_overlay_call = name == self.overlay.name()
38            || normalize_tool_ref(name)
39                .as_deref()
40                .is_some_and(|normalized| normalized == self.overlay.name());
41        if is_overlay_call {
42            // Gate the overlay tool through the base executor's real permission
43            // check BEFORE invoking it (issue #341). The permission checker lives
44            // in the base (built-in) executor, so overlay tools (`memory`,
45            // `scheduler`, `SubAgent`, …) used to bypass it entirely. `Some`
46            // is the interactive approval pause; `Err` is deny / fail-closed.
47            if let Some(outcome) = self.base.check_permissions_for(call, &ctx).await? {
48                return Ok(outcome.into_tool_result());
49            }
50            let args_raw = call.function.arguments.trim();
51            let (args, parse_warning) = parse_tool_args_best_effort(&call.function.arguments);
52            if let Some(warning) = parse_warning {
53                tracing::warn!(
54                    "Overlay tool argument parsing fallback applied: tool_call_id={}, tool_name={}, args_len={}, warning={}",
55                    call.id,
56                    call.function.name,
57                    args_raw.len(),
58                    warning
59                );
60            }
61            return self
62                .overlay
63                .invoke(args, ctx.to_tool_ctx())
64                .await
65                .map(|outcome| outcome.into_tool_result());
66        }
67        self.base.execute_with_context(call, ctx).await
68    }
69
70    async fn execute_with_context_outcome(
71        &self,
72        call: &ToolCall,
73        ctx: ToolExecutionContext<'_>,
74    ) -> Result<ToolOutcome, ToolError> {
75        let name = normalize_tool_name(&call.function.name);
76        let is_overlay_call = name == self.overlay.name()
77            || normalize_tool_ref(name)
78                .as_deref()
79                .is_some_and(|normalized| normalized == self.overlay.name());
80        if is_overlay_call {
81            // Same permission gate as `execute_with_context` (issue #341): the
82            // overlay tool is checked by the base executor before it runs.
83            if let Some(outcome) = self.base.check_permissions_for(call, &ctx).await? {
84                return Ok(outcome);
85            }
86            let (args, _) = parse_tool_args_best_effort(&call.function.arguments);
87            return self.overlay.invoke(args, ctx.to_tool_ctx()).await;
88        }
89        self.base.execute_with_context_outcome(call, ctx).await
90    }
91
92    /// Delegate the permission gate to the base executor so stacked overlays
93    /// chain down to the built-in executor's real check (issue #341). A wrapper
94    /// must never silently answer "allowed" — it defers to whatever it wraps.
95    async fn check_permissions_for(
96        &self,
97        call: &ToolCall,
98        ctx: &ToolExecutionContext<'_>,
99    ) -> Result<Option<ToolOutcome>, ToolError> {
100        self.base.check_permissions_for(call, ctx).await
101    }
102
103    fn list_tools(&self) -> Vec<ToolSchema> {
104        let mut tools = self.base.list_tools();
105
106        // Ensure overlay tool is present exactly once.
107        let overlay_schema = self.overlay.to_schema();
108        let overlay_name = overlay_schema.function.name.clone();
109        tools.retain(|t| t.function.name != overlay_name);
110        tools.push(overlay_schema);
111
112        tools.sort_by_key(|t| t.function.name.clone());
113        tools
114    }
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120
121    use serde_json::json;
122
123    use bamboo_agent_core::tools::{FunctionCall, ToolCtx};
124
125    struct BaseExecutor;
126
127    #[async_trait]
128    impl ToolExecutor for BaseExecutor {
129        async fn execute(&self, call: &ToolCall) -> Result<ToolResult, ToolError> {
130            Err(ToolError::Execution(format!(
131                "base executor called for {}",
132                call.function.name
133            )))
134        }
135
136        async fn execute_with_context(
137            &self,
138            call: &ToolCall,
139            _ctx: ToolExecutionContext<'_>,
140        ) -> Result<ToolResult, ToolError> {
141            self.execute(call).await
142        }
143
144        fn list_tools(&self) -> Vec<ToolSchema> {
145            Vec::new()
146        }
147    }
148
149    struct SubAgentOverlayTool;
150
151    #[async_trait]
152    impl Tool for SubAgentOverlayTool {
153        fn name(&self) -> &str {
154            "SubAgent"
155        }
156
157        fn description(&self) -> &str {
158            "overlay sub agent"
159        }
160
161        fn parameters_schema(&self) -> serde_json::Value {
162            json!({"type":"object","properties":{}})
163        }
164
165        async fn invoke(
166            &self,
167            _args: serde_json::Value,
168            _ctx: ToolCtx,
169        ) -> Result<ToolOutcome, ToolError> {
170            Ok(ToolOutcome::Completed(ToolResult {
171                success: true,
172                result: "overlay".to_string(),
173                display_preference: None,
174                images: Vec::new(),
175            }))
176        }
177    }
178
179    fn make_call(name: &str) -> ToolCall {
180        ToolCall {
181            id: "call_1".to_string(),
182            tool_type: "function".to_string(),
183            function: FunctionCall {
184                name: name.to_string(),
185                arguments: "{}".to_string(),
186            },
187        }
188    }
189
190    #[tokio::test]
191    async fn overlay_executor_routes_spawn_alias_to_overlay_tool() {
192        let overlay = OverlayToolExecutor::new(
193            std::sync::Arc::new(BaseExecutor),
194            std::sync::Arc::new(SubAgentOverlayTool),
195        );
196
197        let result = overlay
198            .execute(&make_call("sub_task"))
199            .await
200            .expect("spawn alias should route to overlay");
201
202        assert!(result.success);
203        assert_eq!(result.result, "overlay");
204    }
205
206    #[tokio::test]
207    async fn overlay_executor_keeps_non_overlay_calls_on_base_executor() {
208        let overlay = OverlayToolExecutor::new(
209            std::sync::Arc::new(BaseExecutor),
210            std::sync::Arc::new(SubAgentOverlayTool),
211        );
212
213        let err = overlay
214            .execute(&make_call("Read"))
215            .await
216            .expect_err("non-overlay call should stay on base executor");
217
218        assert!(
219            matches!(err, ToolError::Execution(msg) if msg.contains("base executor called for Read"))
220        );
221    }
222
223    // ---- issue #341: overlay tools must hit the base permission gate ---------
224
225    use std::sync::atomic::{AtomicBool, Ordering};
226
227    fn make_call_with_args(name: &str, args: &str) -> ToolCall {
228        ToolCall {
229            id: "call_1".to_string(),
230            tool_type: "function".to_string(),
231            function: FunctionCall {
232                name: name.to_string(),
233                arguments: args.to_string(),
234            },
235        }
236    }
237
238    /// An overlay tool named `memory` that records whether it was actually
239    /// invoked, so a test can prove the permission gate blocked it BEFORE it ran.
240    struct RecordingMemoryOverlayTool {
241        invoked: std::sync::Arc<AtomicBool>,
242    }
243
244    #[async_trait]
245    impl Tool for RecordingMemoryOverlayTool {
246        fn name(&self) -> &str {
247            "memory"
248        }
249
250        fn description(&self) -> &str {
251            "overlay memory tool"
252        }
253
254        fn parameters_schema(&self) -> serde_json::Value {
255            json!({"type":"object","properties":{"action":{"type":"string"}}})
256        }
257
258        async fn invoke(
259            &self,
260            _args: serde_json::Value,
261            _ctx: ToolCtx,
262        ) -> Result<ToolOutcome, ToolError> {
263            self.invoked.store(true, Ordering::SeqCst);
264            Ok(ToolOutcome::Completed(ToolResult {
265                success: true,
266                result: "purged".to_string(),
267                display_preference: None,
268                images: Vec::new(),
269            }))
270        }
271    }
272
273    #[tokio::test]
274    async fn overlay_memory_purge_is_denied_by_base_permission_gate() {
275        // Full composition: an OverlayToolExecutor over a real BuiltinToolExecutor
276        // that carries a permission checker (deny-dangerous). A destructive
277        // `memory purge` overlay call must now route through the base's permission
278        // check (which classifies it as a durable WriteFile) and be DENIED instead
279        // of silently invoked — the exact hole issue #341 fixed.
280        let invoked = std::sync::Arc::new(AtomicBool::new(false));
281        let base = bamboo_tools::BuiltinToolExecutor::new_with_permissions(std::sync::Arc::new(
282            bamboo_tools::permission::DenyDangerousPermissionChecker,
283        ));
284        let overlay = OverlayToolExecutor::new(
285            std::sync::Arc::new(base),
286            std::sync::Arc::new(RecordingMemoryOverlayTool {
287                invoked: invoked.clone(),
288            }),
289        );
290
291        let call = make_call_with_args("memory", r#"{"action":"purge"}"#);
292        let result = overlay.execute(&call).await;
293
294        assert!(
295            matches!(result, Err(ToolError::Execution(_))),
296            "gated overlay call must be denied, got: {result:?}"
297        );
298        assert!(
299            !invoked.load(Ordering::SeqCst),
300            "overlay tool must NOT run when the permission gate denies it"
301        );
302    }
303
304    #[tokio::test]
305    async fn overlay_read_only_memory_action_passes_gate_and_runs() {
306        // Control: a read-only `memory query` is NOT classified as a write, so the
307        // gate is a no-op and the overlay tool still runs — proving the gate only
308        // blocks the actions it should, not every overlay call.
309        let invoked = std::sync::Arc::new(AtomicBool::new(false));
310        let base = bamboo_tools::BuiltinToolExecutor::new_with_permissions(std::sync::Arc::new(
311            bamboo_tools::permission::DenyDangerousPermissionChecker,
312        ));
313        let overlay = OverlayToolExecutor::new(
314            std::sync::Arc::new(base),
315            std::sync::Arc::new(RecordingMemoryOverlayTool {
316                invoked: invoked.clone(),
317            }),
318        );
319
320        let call = make_call_with_args("memory", r#"{"action":"query"}"#);
321        let result = overlay
322            .execute(&call)
323            .await
324            .expect("read-only overlay action should pass the gate");
325
326        assert!(result.success);
327        assert_eq!(result.result, "purged");
328        assert!(
329            invoked.load(Ordering::SeqCst),
330            "read-only overlay action must actually run"
331        );
332    }
333
334    /// A base executor whose permission gate always denies, and whose execute
335    /// paths would panic if reached — proves the overlay consults the base's
336    /// `check_permissions_for` and short-circuits on `Err` before invoking.
337    struct GateDenyingBaseExecutor;
338
339    #[async_trait]
340    impl ToolExecutor for GateDenyingBaseExecutor {
341        async fn execute(&self, _call: &ToolCall) -> Result<ToolResult, ToolError> {
342            panic!("base execute must not be reached for a denied overlay call");
343        }
344
345        async fn execute_with_context(
346            &self,
347            _call: &ToolCall,
348            _ctx: ToolExecutionContext<'_>,
349        ) -> Result<ToolResult, ToolError> {
350            panic!("base execute_with_context must not be reached for a denied overlay call");
351        }
352
353        async fn check_permissions_for(
354            &self,
355            _call: &ToolCall,
356            _ctx: &ToolExecutionContext<'_>,
357        ) -> Result<Option<ToolOutcome>, ToolError> {
358            Err(ToolError::Execution("denied-by-base-gate".to_string()))
359        }
360
361        fn list_tools(&self) -> Vec<ToolSchema> {
362            Vec::new()
363        }
364    }
365
366    #[tokio::test]
367    async fn overlay_call_short_circuits_on_base_gate_error_before_invoke() {
368        // Minimal proof of routing: the overlay call reaches the base's
369        // `check_permissions_for`, and its `Err` is returned before the overlay
370        // tool's `invoke` runs (the overlay tool records if it ran).
371        let invoked = std::sync::Arc::new(AtomicBool::new(false));
372        let overlay = OverlayToolExecutor::new(
373            std::sync::Arc::new(GateDenyingBaseExecutor),
374            std::sync::Arc::new(RecordingMemoryOverlayTool {
375                invoked: invoked.clone(),
376            }),
377        );
378
379        let call = make_call_with_args("memory", r#"{"action":"purge"}"#);
380        let err = overlay
381            .execute(&call)
382            .await
383            .expect_err("base gate error must short-circuit the overlay call");
384
385        assert!(
386            matches!(err, ToolError::Execution(msg) if msg.contains("denied-by-base-gate")),
387            "overlay must return the base gate's error verbatim"
388        );
389        assert!(
390            !invoked.load(Ordering::SeqCst),
391            "overlay tool must NOT run when the base gate errors"
392        );
393    }
394
395    #[tokio::test]
396    async fn overlay_check_permissions_for_delegates_to_base() {
397        // The overlay's own `check_permissions_for` delegates to the base, so a
398        // wrapper stacked over this overlay chains down to the real check.
399        let overlay = OverlayToolExecutor::new(
400            std::sync::Arc::new(GateDenyingBaseExecutor),
401            std::sync::Arc::new(RecordingMemoryOverlayTool {
402                invoked: std::sync::Arc::new(AtomicBool::new(false)),
403            }),
404        );
405
406        let call = make_call_with_args("memory", r#"{"action":"purge"}"#);
407        let ctx = ToolExecutionContext::none(&call.id);
408        let result = overlay.check_permissions_for(&call, &ctx).await;
409
410        assert!(
411            matches!(result, Err(ToolError::Execution(ref msg)) if msg.contains("denied-by-base-gate")),
412            "overlay check_permissions_for must return the base's decision, got: {result:?}"
413        );
414    }
415}