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    /// Resolve the args to hand the overlay tool, parsing the raw JSON at most
24    /// once (issue #106). When the dispatch loop already parsed them (threaded via
25    /// `ctx.pre_parsed_args`), reuse that value — the malformed-args fallback
26    /// `warn!` was already emitted (or not) at the dispatch site, so it is never
27    /// re-emitted here. Otherwise parse leniently, warning once on fallback,
28    /// preserving the original single-parse-per-consumer behavior.
29    fn resolve_args(&self, call: &ToolCall, ctx: &ToolExecutionContext<'_>) -> serde_json::Value {
30        if let Some(pre_parsed) = ctx.pre_parsed_args {
31            return pre_parsed.clone();
32        }
33        let args_raw = call.function.arguments.trim();
34        let (args, parse_warning) = parse_tool_args_best_effort(&call.function.arguments);
35        if let Some(warning) = parse_warning {
36            tracing::warn!(
37                "Overlay tool argument parsing fallback applied: tool_call_id={}, tool_name={}, args_len={}, warning={}",
38                call.id,
39                call.function.name,
40                args_raw.len(),
41                warning
42            );
43        }
44        args
45    }
46}
47
48#[async_trait]
49impl ToolExecutor for OverlayToolExecutor {
50    async fn execute(&self, call: &ToolCall) -> Result<ToolResult, ToolError> {
51        self.execute_with_context(call, ToolExecutionContext::none(&call.id))
52            .await
53    }
54
55    async fn execute_with_context(
56        &self,
57        call: &ToolCall,
58        ctx: ToolExecutionContext<'_>,
59    ) -> Result<ToolResult, ToolError> {
60        let name = normalize_tool_name(&call.function.name);
61        let is_overlay_call = name == self.overlay.name()
62            || normalize_tool_ref(name)
63                .as_deref()
64                .is_some_and(|normalized| normalized == self.overlay.name());
65        if is_overlay_call {
66            // Gate the overlay tool through the base executor's real permission
67            // check BEFORE invoking it (issue #341). The permission checker lives
68            // in the base (built-in) executor, so overlay tools (`memory`,
69            // `scheduler`, `SubAgent`, …) used to bypass it entirely. `Some`
70            // is the interactive approval pause; `Err` is deny / fail-closed.
71            if let Some(outcome) = self.base.check_permissions_for(call, &ctx).await? {
72                return Ok(outcome.into_tool_result());
73            }
74            // Reuse the args the dispatching loop already parsed (threaded via the
75            // context) instead of re-parsing the raw JSON string a second time here
76            // (issue #106). The threaded value is the exact output of the same
77            // parser on the same input, so reuse is behavior-preserving — and it
78            // means the malformed-args fallback `warn!` fires at most once per call
79            // (at the dispatch site), never re-emitted here. When absent (`none()`
80            // contexts, tests, synthesized child calls), parse leniently exactly as
81            // before, including the fallback warning.
82            let args = self.resolve_args(call, &ctx);
83            return self
84                .overlay
85                .invoke(args, ctx.to_tool_ctx())
86                .await
87                .map(|outcome| outcome.into_tool_result());
88        }
89        self.base.execute_with_context(call, ctx).await
90    }
91
92    async fn execute_with_context_outcome(
93        &self,
94        call: &ToolCall,
95        ctx: ToolExecutionContext<'_>,
96    ) -> Result<ToolOutcome, ToolError> {
97        let name = normalize_tool_name(&call.function.name);
98        let is_overlay_call = name == self.overlay.name()
99            || normalize_tool_ref(name)
100                .as_deref()
101                .is_some_and(|normalized| normalized == self.overlay.name());
102        if is_overlay_call {
103            // Same permission gate as `execute_with_context` (issue #341): the
104            // overlay tool is checked by the base executor before it runs.
105            if let Some(outcome) = self.base.check_permissions_for(call, &ctx).await? {
106                return Ok(outcome);
107            }
108            // Reuse the dispatch-parsed args (parse-once) exactly as in
109            // `execute_with_context` above (issue #106).
110            let args = self.resolve_args(call, &ctx);
111            return self.overlay.invoke(args, ctx.to_tool_ctx()).await;
112        }
113        self.base.execute_with_context_outcome(call, ctx).await
114    }
115
116    /// Delegate the permission gate to the base executor so stacked overlays
117    /// chain down to the built-in executor's real check (issue #341). A wrapper
118    /// must never silently answer "allowed" — it defers to whatever it wraps.
119    async fn check_permissions_for(
120        &self,
121        call: &ToolCall,
122        ctx: &ToolExecutionContext<'_>,
123    ) -> Result<Option<ToolOutcome>, ToolError> {
124        self.base.check_permissions_for(call, ctx).await
125    }
126
127    fn list_tools(&self) -> Vec<ToolSchema> {
128        let mut tools = self.base.list_tools();
129
130        // Ensure overlay tool is present exactly once.
131        let overlay_schema = self.overlay.to_schema();
132        let overlay_name = overlay_schema.function.name.clone();
133        tools.retain(|t| t.function.name != overlay_name);
134        tools.push(overlay_schema);
135
136        tools.sort_by_key(|t| t.function.name.clone());
137        tools
138    }
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144
145    use serde_json::json;
146
147    use bamboo_agent_core::tools::{FunctionCall, ToolCtx};
148
149    struct BaseExecutor;
150
151    #[async_trait]
152    impl ToolExecutor for BaseExecutor {
153        async fn execute(&self, call: &ToolCall) -> Result<ToolResult, ToolError> {
154            Err(ToolError::Execution(format!(
155                "base executor called for {}",
156                call.function.name
157            )))
158        }
159
160        async fn execute_with_context(
161            &self,
162            call: &ToolCall,
163            _ctx: ToolExecutionContext<'_>,
164        ) -> Result<ToolResult, ToolError> {
165            self.execute(call).await
166        }
167
168        fn list_tools(&self) -> Vec<ToolSchema> {
169            Vec::new()
170        }
171    }
172
173    struct SubAgentOverlayTool;
174
175    #[async_trait]
176    impl Tool for SubAgentOverlayTool {
177        fn name(&self) -> &str {
178            "SubAgent"
179        }
180
181        fn description(&self) -> &str {
182            "overlay sub agent"
183        }
184
185        fn parameters_schema(&self) -> serde_json::Value {
186            json!({"type":"object","properties":{}})
187        }
188
189        async fn invoke(
190            &self,
191            _args: serde_json::Value,
192            _ctx: ToolCtx,
193        ) -> Result<ToolOutcome, ToolError> {
194            Ok(ToolOutcome::Completed(ToolResult {
195                success: true,
196                result: "overlay".to_string(),
197                display_preference: None,
198                images: Vec::new(),
199            }))
200        }
201    }
202
203    fn make_call(name: &str) -> ToolCall {
204        ToolCall {
205            id: "call_1".to_string(),
206            tool_type: "function".to_string(),
207            function: FunctionCall {
208                name: name.to_string(),
209                arguments: "{}".to_string(),
210            },
211        }
212    }
213
214    #[tokio::test]
215    async fn overlay_executor_routes_spawn_alias_to_overlay_tool() {
216        let overlay = OverlayToolExecutor::new(
217            std::sync::Arc::new(BaseExecutor),
218            std::sync::Arc::new(SubAgentOverlayTool),
219        );
220
221        let result = overlay
222            .execute(&make_call("sub_task"))
223            .await
224            .expect("spawn alias should route to overlay");
225
226        assert!(result.success);
227        assert_eq!(result.result, "overlay");
228    }
229
230    #[tokio::test]
231    async fn overlay_executor_keeps_non_overlay_calls_on_base_executor() {
232        let overlay = OverlayToolExecutor::new(
233            std::sync::Arc::new(BaseExecutor),
234            std::sync::Arc::new(SubAgentOverlayTool),
235        );
236
237        let err = overlay
238            .execute(&make_call("Read"))
239            .await
240            .expect_err("non-overlay call should stay on base executor");
241
242        assert!(
243            matches!(err, ToolError::Execution(msg) if msg.contains("base executor called for Read"))
244        );
245    }
246
247    // ---- issue #341: overlay tools must hit the base permission gate ---------
248
249    use std::sync::atomic::{AtomicBool, Ordering};
250
251    fn make_call_with_args(name: &str, args: &str) -> ToolCall {
252        ToolCall {
253            id: "call_1".to_string(),
254            tool_type: "function".to_string(),
255            function: FunctionCall {
256                name: name.to_string(),
257                arguments: args.to_string(),
258            },
259        }
260    }
261
262    /// An overlay tool named `memory` that records whether it was actually
263    /// invoked, so a test can prove the permission gate blocked it BEFORE it ran.
264    struct RecordingMemoryOverlayTool {
265        invoked: std::sync::Arc<AtomicBool>,
266    }
267
268    #[async_trait]
269    impl Tool for RecordingMemoryOverlayTool {
270        fn name(&self) -> &str {
271            "memory"
272        }
273
274        fn description(&self) -> &str {
275            "overlay memory tool"
276        }
277
278        fn parameters_schema(&self) -> serde_json::Value {
279            json!({"type":"object","properties":{"action":{"type":"string"}}})
280        }
281
282        async fn invoke(
283            &self,
284            _args: serde_json::Value,
285            _ctx: ToolCtx,
286        ) -> Result<ToolOutcome, ToolError> {
287            self.invoked.store(true, Ordering::SeqCst);
288            Ok(ToolOutcome::Completed(ToolResult {
289                success: true,
290                result: "purged".to_string(),
291                display_preference: None,
292                images: Vec::new(),
293            }))
294        }
295    }
296
297    #[tokio::test]
298    async fn overlay_memory_purge_is_denied_by_base_permission_gate() {
299        // Full composition: an OverlayToolExecutor over a real BuiltinToolExecutor
300        // that carries a permission checker (deny-dangerous). A destructive
301        // `memory purge` overlay call must now route through the base's permission
302        // check (which classifies it as a durable WriteFile) and be DENIED instead
303        // of silently invoked — the exact hole issue #341 fixed.
304        let invoked = std::sync::Arc::new(AtomicBool::new(false));
305        let base = bamboo_tools::BuiltinToolExecutor::new_with_permissions(std::sync::Arc::new(
306            bamboo_tools::permission::DenyDangerousPermissionChecker,
307        ));
308        let overlay = OverlayToolExecutor::new(
309            std::sync::Arc::new(base),
310            std::sync::Arc::new(RecordingMemoryOverlayTool {
311                invoked: invoked.clone(),
312            }),
313        );
314
315        let call = make_call_with_args("memory", r#"{"action":"purge"}"#);
316        let result = overlay.execute(&call).await;
317
318        assert!(
319            matches!(result, Err(ToolError::Execution(_))),
320            "gated overlay call must be denied, got: {result:?}"
321        );
322        assert!(
323            !invoked.load(Ordering::SeqCst),
324            "overlay tool must NOT run when the permission gate denies it"
325        );
326    }
327
328    #[tokio::test]
329    async fn overlay_read_only_memory_action_passes_gate_and_runs() {
330        // Control: a read-only `memory query` is NOT classified as a write, so the
331        // gate is a no-op and the overlay tool still runs — proving the gate only
332        // blocks the actions it should, not every overlay call.
333        let invoked = std::sync::Arc::new(AtomicBool::new(false));
334        let base = bamboo_tools::BuiltinToolExecutor::new_with_permissions(std::sync::Arc::new(
335            bamboo_tools::permission::DenyDangerousPermissionChecker,
336        ));
337        let overlay = OverlayToolExecutor::new(
338            std::sync::Arc::new(base),
339            std::sync::Arc::new(RecordingMemoryOverlayTool {
340                invoked: invoked.clone(),
341            }),
342        );
343
344        let call = make_call_with_args("memory", r#"{"action":"query"}"#);
345        let result = overlay
346            .execute(&call)
347            .await
348            .expect("read-only overlay action should pass the gate");
349
350        assert!(result.success);
351        assert_eq!(result.result, "purged");
352        assert!(
353            invoked.load(Ordering::SeqCst),
354            "read-only overlay action must actually run"
355        );
356    }
357
358    /// A base executor whose permission gate always denies, and whose execute
359    /// paths would panic if reached — proves the overlay consults the base's
360    /// `check_permissions_for` and short-circuits on `Err` before invoking.
361    struct GateDenyingBaseExecutor;
362
363    #[async_trait]
364    impl ToolExecutor for GateDenyingBaseExecutor {
365        async fn execute(&self, _call: &ToolCall) -> Result<ToolResult, ToolError> {
366            panic!("base execute must not be reached for a denied overlay call");
367        }
368
369        async fn execute_with_context(
370            &self,
371            _call: &ToolCall,
372            _ctx: ToolExecutionContext<'_>,
373        ) -> Result<ToolResult, ToolError> {
374            panic!("base execute_with_context must not be reached for a denied overlay call");
375        }
376
377        async fn check_permissions_for(
378            &self,
379            _call: &ToolCall,
380            _ctx: &ToolExecutionContext<'_>,
381        ) -> Result<Option<ToolOutcome>, ToolError> {
382            Err(ToolError::Execution("denied-by-base-gate".to_string()))
383        }
384
385        fn list_tools(&self) -> Vec<ToolSchema> {
386            Vec::new()
387        }
388    }
389
390    #[tokio::test]
391    async fn overlay_call_short_circuits_on_base_gate_error_before_invoke() {
392        // Minimal proof of routing: the overlay call reaches the base's
393        // `check_permissions_for`, and its `Err` is returned before the overlay
394        // tool's `invoke` runs (the overlay tool records if it ran).
395        let invoked = std::sync::Arc::new(AtomicBool::new(false));
396        let overlay = OverlayToolExecutor::new(
397            std::sync::Arc::new(GateDenyingBaseExecutor),
398            std::sync::Arc::new(RecordingMemoryOverlayTool {
399                invoked: invoked.clone(),
400            }),
401        );
402
403        let call = make_call_with_args("memory", r#"{"action":"purge"}"#);
404        let err = overlay
405            .execute(&call)
406            .await
407            .expect_err("base gate error must short-circuit the overlay call");
408
409        assert!(
410            matches!(err, ToolError::Execution(msg) if msg.contains("denied-by-base-gate")),
411            "overlay must return the base gate's error verbatim"
412        );
413        assert!(
414            !invoked.load(Ordering::SeqCst),
415            "overlay tool must NOT run when the base gate errors"
416        );
417    }
418
419    #[tokio::test]
420    async fn overlay_check_permissions_for_delegates_to_base() {
421        // The overlay's own `check_permissions_for` delegates to the base, so a
422        // wrapper stacked over this overlay chains down to the real check.
423        let overlay = OverlayToolExecutor::new(
424            std::sync::Arc::new(GateDenyingBaseExecutor),
425            std::sync::Arc::new(RecordingMemoryOverlayTool {
426                invoked: std::sync::Arc::new(AtomicBool::new(false)),
427            }),
428        );
429
430        let call = make_call_with_args("memory", r#"{"action":"purge"}"#);
431        let ctx = ToolExecutionContext::none(&call.id);
432        let result = overlay.check_permissions_for(&call, &ctx).await;
433
434        assert!(
435            matches!(result, Err(ToolError::Execution(ref msg)) if msg.contains("denied-by-base-gate")),
436            "overlay check_permissions_for must return the base's decision, got: {result:?}"
437        );
438    }
439
440    // ---- issue #106: overlay reuses pre-parsed args (parse-once, no re-warn) ---
441
442    use std::sync::atomic::AtomicUsize;
443    use std::sync::Mutex as StdMutex;
444
445    /// An overlay tool that records the exact args `Value` it was invoked with, so
446    /// a test can prove WHICH value the overlay passed through: the threaded
447    /// pre-parsed value, or a re-parse of the raw string.
448    struct ArgsRecordingOverlayTool {
449        seen: std::sync::Arc<StdMutex<Option<serde_json::Value>>>,
450    }
451
452    #[async_trait]
453    impl Tool for ArgsRecordingOverlayTool {
454        fn name(&self) -> &str {
455            "memory"
456        }
457
458        fn description(&self) -> &str {
459            "records the args it was invoked with"
460        }
461
462        fn parameters_schema(&self) -> serde_json::Value {
463            json!({"type":"object","properties":{}})
464        }
465
466        async fn invoke(
467            &self,
468            args: serde_json::Value,
469            _ctx: ToolCtx,
470        ) -> Result<ToolOutcome, ToolError> {
471            *self.seen.lock().unwrap() = Some(args);
472            Ok(ToolOutcome::Completed(ToolResult {
473                success: true,
474                result: "ok".to_string(),
475                display_preference: None,
476                images: Vec::new(),
477            }))
478        }
479    }
480
481    /// Minimal tracing subscriber that counts WARN-level events on the current
482    /// thread — lets a test assert whether the malformed-args fallback `warn!`
483    /// fired, without pulling in `tracing-subscriber` as a dev-dependency.
484    #[derive(Clone, Default)]
485    struct WarnCounter {
486        warns: std::sync::Arc<AtomicUsize>,
487    }
488
489    impl tracing::Subscriber for WarnCounter {
490        fn enabled(&self, _m: &tracing::Metadata<'_>) -> bool {
491            true
492        }
493        fn new_span(&self, _a: &tracing::span::Attributes<'_>) -> tracing::span::Id {
494            tracing::span::Id::from_u64(1)
495        }
496        fn record(&self, _s: &tracing::span::Id, _v: &tracing::span::Record<'_>) {}
497        fn record_follows_from(&self, _s: &tracing::span::Id, _f: &tracing::span::Id) {}
498        fn event(&self, event: &tracing::Event<'_>) {
499            if *event.metadata().level() == tracing::Level::WARN {
500                self.warns.fetch_add(1, Ordering::SeqCst);
501            }
502        }
503        fn enter(&self, _s: &tracing::span::Id) {}
504        fn exit(&self, _s: &tracing::span::Id) {}
505    }
506
507    #[tokio::test]
508    async fn overlay_reuses_pre_parsed_args_and_skips_refallback_warn() {
509        // The dispatch loop already parsed the args once and threaded them via
510        // `ctx.pre_parsed_args`. The overlay must reuse that value rather than
511        // re-parse `call.function.arguments` — so even MALFORMED raw args do NOT
512        // trigger a second parse (nor its malformed-args fallback `warn!`).
513        let seen = std::sync::Arc::new(StdMutex::new(None));
514        let overlay = OverlayToolExecutor::new(
515            std::sync::Arc::new(BaseExecutor),
516            std::sync::Arc::new(ArgsRecordingOverlayTool { seen: seen.clone() }),
517        );
518
519        // Distinctive parsed value; raw args are deliberately broken so a re-parse
520        // would fall back to `{}` AND warn — neither must happen.
521        let pre_parsed = json!({"action": "query", "threaded": true});
522        let call = make_call_with_args("memory", "{ this is not valid json");
523        let mut ctx = ToolExecutionContext::none(&call.id);
524        ctx.pre_parsed_args = Some(&pre_parsed);
525
526        let counter = WarnCounter::default();
527        let warns = counter.warns.clone();
528        {
529            let _guard = tracing::subscriber::set_default(counter);
530            overlay
531                .execute_with_context(&call, ctx)
532                .await
533                .expect("overlay call should succeed");
534        }
535
536        assert_eq!(
537            seen.lock().unwrap().clone(),
538            Some(pre_parsed),
539            "overlay must invoke with the threaded pre-parsed args, not a re-parse of the raw string"
540        );
541        assert_eq!(
542            warns.load(Ordering::SeqCst),
543            0,
544            "reusing pre-parsed args must NOT re-emit the malformed-args fallback warning"
545        );
546    }
547
548    #[tokio::test]
549    async fn overlay_without_pre_parsed_reparses_and_warns_on_malformed_args() {
550        // Control proving the WarnCounter observes the fallback warn and that the
551        // reuse path above is what suppresses it: with NO pre-parsed args, the
552        // overlay re-parses the malformed raw string, falls back to `{}`, warns once.
553        let seen = std::sync::Arc::new(StdMutex::new(None));
554        let overlay = OverlayToolExecutor::new(
555            std::sync::Arc::new(BaseExecutor),
556            std::sync::Arc::new(ArgsRecordingOverlayTool { seen: seen.clone() }),
557        );
558
559        let call = make_call_with_args("memory", "{ this is not valid json");
560        let ctx = ToolExecutionContext::none(&call.id); // pre_parsed_args = None
561
562        let counter = WarnCounter::default();
563        let warns = counter.warns.clone();
564        {
565            let _guard = tracing::subscriber::set_default(counter);
566            overlay
567                .execute_with_context(&call, ctx)
568                .await
569                .expect("overlay call should succeed");
570        }
571
572        assert_eq!(
573            seen.lock().unwrap().clone(),
574            Some(json!({})),
575            "no pre-parsed args → the malformed raw string parses to the empty-object fallback"
576        );
577        assert_eq!(
578            warns.load(Ordering::SeqCst),
579            1,
580            "the malformed-args fallback must warn exactly once when it actually parses"
581        );
582    }
583}