Skip to main content

bashkit_scripted_tool/scripted_tool/
execute.rs

1//! ScriptedTool execution: Tool impl and documentation helpers.
2
3use super::{ScriptedExecutionTrace, ScriptedTool, ToolDefExtension, extension::InvocationLog};
4use crate::tool_def::usage_from_schema;
5use async_trait::async_trait;
6use bashkit::Bash;
7use bashkit::tool::timeout_response;
8use bashkit::tool::{
9    Tool, ToolError, ToolExecution, ToolOutputChunk, ToolRequest, ToolResponse, ToolStatus,
10    VERSION, localized, tool_output_from_response, tool_request_from_value, tool_request_schema,
11    tool_response_schema,
12};
13use bashkit::{Builtin, BuiltinContext as Context, ExecutionPlan};
14use bashkit::{Error, ExecResult};
15use std::collections::VecDeque;
16use std::sync::{Arc, Mutex};
17use std::time::Duration;
18
19/// Keeps fatal host diagnostics behind ScriptedTool's callback-error boundary.
20///
21// THREAT[TM-INF-030]: this wrapper must forward *every* `Builtin` method. A
22// method left to its trait default silently replaces the wrapped builtin's
23// own override, so adding one to `Builtin` means adding it here too.
24struct ScriptedBuiltin {
25    inner: Arc<dyn Builtin>,
26    sanitize_errors: bool,
27}
28
29impl ScriptedBuiltin {
30    fn sanitize<T>(&self, result: bashkit::Result<T>) -> bashkit::Result<T> {
31        match result {
32            Err(err)
33                if self.sanitize_errors
34                    && !matches!(err, Error::Cancelled | Error::ResourceLimit(_)) =>
35            {
36                Err(Error::Execution("callback failed".into()))
37            }
38            result => result,
39        }
40    }
41}
42
43#[async_trait]
44impl Builtin for ScriptedBuiltin {
45    async fn execute(&self, ctx: Context<'_>) -> bashkit::Result<ExecResult> {
46        let result = self.inner.execute(ctx).await;
47        self.sanitize(result)
48    }
49
50    async fn execution_plan(&self, ctx: &Context<'_>) -> bashkit::Result<Option<ExecutionPlan>> {
51        let result = self.inner.execution_plan(ctx).await;
52        self.sanitize(result)
53    }
54
55    fn llm_hint(&self) -> Option<&'static str> {
56        self.inner.llm_hint()
57    }
58
59    fn reset_session_state(&self) {
60        self.inner.reset_session_state();
61    }
62}
63
64// ============================================================================
65// ScriptedTool — internal helpers
66// ============================================================================
67
68impl ScriptedTool {
69    /// Create a fresh Bash instance with all tool builtins registered.
70    fn create_bash(&self, log: InvocationLog) -> Bash {
71        let mut builder = Bash::builder();
72        if let Some(ref profile) = self.profile {
73            builder = builder.profile(profile.clone());
74        }
75        builder = super::logic_only::apply(builder);
76
77        if let Some(ref limits) = self.limits {
78            builder = builder.limits(limits.clone());
79        }
80        for (key, value) in &self.env_vars {
81            builder = builder.env(key, value);
82        }
83        // Custom builtins go in before the ToolDef extension: `BashBuilder` keys
84        // custom builtins by name and later registrations win, so installing the
85        // extension last means a custom builtin can never shadow a registered
86        // tool command, `help`, or `discover`.
87        for (name, builtin) in &self.builtins {
88            builder = builder.builtin(
89                name.clone(),
90                Box::new(ScriptedBuiltin {
91                    inner: Arc::clone(builtin),
92                    sanitize_errors: self.sanitize_errors,
93                }),
94            );
95        }
96        builder = builder.extension(
97            ToolDefExtension::from_registered_tools(self.tools.clone())
98                .sanitize_errors(self.sanitize_errors)
99                .with_invocation_log(log),
100        );
101
102        builder.build()
103    }
104
105    fn build_help(&self) -> String {
106        let mut doc = format!(
107            "# {}\n\n{}\n\n**Version:** {}\n**Name:** `{}`\n**Locale:** `{}`\n\n## Parameters\n\n| Name | Type | Required | Default | Description |\n|------|------|----------|---------|-------------|\n| `commands` | string | yes | — | Bash script that may call the registered tool commands |\n| `timeout_ms` | integer | no | — | Per-call timeout in milliseconds |\n\n## Tool Commands\n\n| Name | Description | Usage |\n|------|-------------|-------|\n",
108            self.display_name, self.description, VERSION, self.name, self.locale
109        );
110
111        for t in &self.tools {
112            let usage = usage_from_schema(&t.def.input_schema)
113                .map(|u| format!("`{} {}`", t.def.name, u))
114                .unwrap_or_else(|| format!("`{}`", t.def.name));
115            doc.push_str(&format!(
116                "| `{}` | {} | {} |\n",
117                t.def.name, t.def.description, usage
118            ));
119        }
120
121        doc.push_str(
122            "\n## Result\n\n| Field | Type | Description |\n|------|------|-------------|\n| `stdout` | string | Combined standard output |\n| `stderr` | string | Tool or bash errors |\n| `exit_code` | integer | Shell exit code |\n| `error` | string | Error category when execution fails |\n\n## Examples\n\n```json\n{\"commands\":\"get_user --id 42\"}\n```\n\n```json\n{\"commands\":\"user=$(get_user --id 42)\\necho \\\"$user\\\" | jq -r '.name'\"}\n```\n\n## Notes\n\n- Pass arguments as `--key value` or `--key=value`.\n- Shell logic, variables, loops, conditionals, pipes, heredocs, here-strings, and stdin-based transforms are available.\n- Filesystem primitives are unavailable: file commands, path script execution, file redirection, and process substitution are rejected.\n- Use `help <tool> --json` inside the tool for runtime schema inspection.\n",
123        );
124
125        doc
126    }
127
128    fn build_system_prompt(&self) -> String {
129        let mut parts = vec![format!(
130            "{}: {}.",
131            self.name,
132            localized(
133                self.locale.as_str(),
134                "run bash scripts that orchestrate registered tool commands",
135                "виконує bash-скрипти для оркестрації зареєстрованих команд",
136            )
137        )];
138
139        let tools = self
140            .tools
141            .iter()
142            .map(|tool| {
143                if self.compact_prompt {
144                    format!("{} ({})", tool.def.name, tool.def.description)
145                } else if let Some(usage) = usage_from_schema(&tool.def.input_schema) {
146                    format!("{} [{}]", tool.def.name, usage)
147                } else {
148                    tool.def.name.clone()
149                }
150            })
151            .collect::<Vec<_>>()
152            .join(", ");
153        parts.push(format!(
154            "{}: {}.",
155            localized(self.locale.as_str(), "Commands", "Команди"),
156            tools
157        ));
158        parts.push(localized(
159            self.locale.as_str(),
160            "Pass args as --key value or --key=value. Shell logic and stdin pipelines are available; filesystem primitives are unavailable. Use help/discover builtins for runtime details.",
161            "Передавайте аргументи як --key value або --key=value. Доступні логіка shell і stdin-конвеєри; файлові примітиви недоступні. Використовуйте help/discover для деталей.",
162        ).to_string());
163
164        parts.join(" ")
165    }
166
167    async fn run_request_with_stream(
168        &self,
169        req: ToolRequest,
170        stream_sender: Option<tokio::sync::mpsc::UnboundedSender<ToolOutputChunk>>,
171    ) -> ToolResponse {
172        if req.commands.is_empty() {
173            self.store_last_execution_trace(ScriptedExecutionTrace::default());
174            return ToolResponse {
175                stdout: String::new(),
176                stderr: String::new(),
177                exit_code: 0,
178                error: None,
179                ..Default::default()
180            };
181        }
182
183        let timeout_ms = req.timeout_ms;
184        let commands = req.commands;
185        let log: InvocationLog = Arc::new(Mutex::new(VecDeque::new()));
186        let mut bash = self.create_bash(Arc::clone(&log));
187
188        let fut = async {
189            let result = if let Some(sender) = stream_sender {
190                let output_cb = Box::new(
191                    move |stdout_chunk: &bashkit::StreamData,
192                          stderr_chunk: &bashkit::StreamData| {
193                        if !stdout_chunk.is_empty() {
194                            let _ = sender.send(ToolOutputChunk {
195                                data: serde_json::json!(stdout_chunk),
196                                kind: "stdout".to_string(),
197                            });
198                        }
199                        if !stderr_chunk.is_empty() {
200                            let _ = sender.send(ToolOutputChunk {
201                                data: serde_json::json!(stderr_chunk),
202                                kind: "stderr".to_string(),
203                            });
204                        }
205                    },
206                );
207                bash.exec_streaming(&commands, output_cb).await
208            } else {
209                bash.exec(&commands).await
210            };
211
212            match result {
213                Ok(result) => result.into(),
214                Err(err) => ToolResponse {
215                    stdout: String::new(),
216                    stderr: err.to_string(),
217                    exit_code: 1,
218                    error: Some(err.to_string()),
219                    ..Default::default()
220                },
221            }
222        };
223
224        // Keep ScriptedTool on the shared ToolRequest contract: per-call
225        // timeouts must abort the whole orchestration, including callbacks.
226        let response = if let Some(ms) = timeout_ms {
227            let duration = Duration::from_millis(ms);
228            match bashkit::time::timeout(duration, fut).await {
229                Ok(response) => response,
230                Err(_) => timeout_response(duration),
231            }
232        } else {
233            fut.await
234        };
235
236        let invocations: Vec<_> = log
237            .lock()
238            .expect("scripted invocation log poisoned")
239            .iter()
240            .cloned()
241            .collect();
242        self.store_last_execution_trace(ScriptedExecutionTrace { invocations });
243        response
244    }
245}
246
247// ============================================================================
248// Tool trait implementation
249// ============================================================================
250
251#[async_trait]
252impl Tool for ScriptedTool {
253    fn name(&self) -> &str {
254        &self.name
255    }
256
257    fn display_name(&self) -> &str {
258        &self.display_name
259    }
260
261    fn short_description(&self) -> &str {
262        &self.short_desc
263    }
264
265    fn description(&self) -> &str {
266        &self.description
267    }
268
269    fn help(&self) -> String {
270        self.build_help()
271    }
272
273    fn system_prompt(&self) -> String {
274        self.build_system_prompt()
275    }
276
277    fn locale(&self) -> &str {
278        &self.locale
279    }
280
281    fn input_schema(&self) -> serde_json::Value {
282        tool_request_schema()
283    }
284
285    fn output_schema(&self) -> serde_json::Value {
286        tool_response_schema()
287    }
288
289    fn version(&self) -> &str {
290        VERSION
291    }
292
293    fn execution(&self, args: serde_json::Value) -> std::result::Result<ToolExecution, ToolError> {
294        let req = tool_request_from_value(self.locale(), args)?;
295        let tool = self.clone();
296        Ok(ToolExecution::new(move |stream_sender| async move {
297            let start = bashkit::time::Instant::now();
298            let response = tool.run_request_with_stream(req, stream_sender).await;
299            tool_output_from_response(response, start.elapsed())
300        }))
301    }
302
303    async fn execute(&self, req: ToolRequest) -> ToolResponse {
304        self.run_request_with_stream(req, None).await
305    }
306
307    async fn execute_with_status(
308        &self,
309        req: ToolRequest,
310        mut status_callback: Box<dyn FnMut(ToolStatus) + Send>,
311    ) -> ToolResponse {
312        status_callback(ToolStatus::new("validate").with_percent(0.0));
313
314        if req.commands.is_empty() {
315            status_callback(ToolStatus::new("complete").with_percent(100.0));
316            return ToolResponse {
317                stdout: String::new(),
318                stderr: String::new(),
319                exit_code: 0,
320                error: None,
321                ..Default::default()
322            };
323        }
324
325        status_callback(ToolStatus::new("parse").with_percent(10.0));
326        status_callback(ToolStatus::new("execute").with_percent(20.0));
327        let response = self.run_request_with_stream(req, None).await;
328
329        status_callback(ToolStatus::new("complete").with_percent(100.0));
330        response
331    }
332}
333
334#[cfg(test)]
335mod tests {
336    use super::*;
337    use crate::ToolArgs;
338    use crate::ToolDef;
339    use crate::tool_def::parse_flags;
340    use bashkit::ExecutionLimits;
341
342    #[test]
343    fn test_parse_flags_key_value() {
344        let schema = serde_json::json!({
345            "type": "object",
346            "properties": {
347                "id": {"type": "integer"},
348                "name": {"type": "string"}
349            }
350        });
351        let args = vec!["--id".into(), "42".into(), "--name".into(), "Alice".into()];
352        let result = parse_flags(&args, &schema).expect("parse_flags should succeed");
353        assert_eq!(result["id"], 42);
354        assert_eq!(result["name"], "Alice");
355    }
356
357    #[test]
358    fn test_parse_flags_equals_syntax() {
359        let schema = serde_json::json!({
360            "type": "object",
361            "properties": { "id": {"type": "integer"} }
362        });
363        let args = vec!["--id=99".into()];
364        let result = parse_flags(&args, &schema).expect("parse_flags should succeed");
365        assert_eq!(result["id"], 99);
366    }
367
368    #[test]
369    fn test_parse_flags_boolean() {
370        let schema = serde_json::json!({
371            "type": "object",
372            "properties": {
373                "verbose": {"type": "boolean"},
374                "query": {"type": "string"}
375            }
376        });
377        let args = vec!["--verbose".into(), "--query".into(), "hello".into()];
378        let result = parse_flags(&args, &schema).expect("parse_flags should succeed");
379        assert_eq!(result["verbose"], true);
380        assert_eq!(result["query"], "hello");
381    }
382
383    #[test]
384    fn test_parse_flags_no_schema() {
385        let schema = serde_json::json!({});
386        let args = vec!["--name".into(), "Bob".into()];
387        let result = parse_flags(&args, &schema).expect("parse_flags should succeed");
388        assert_eq!(result["name"], "Bob");
389    }
390
391    #[test]
392    fn test_parse_flags_empty() {
393        let schema = serde_json::json!({});
394        let result = parse_flags(&[], &schema).expect("parse_flags should succeed");
395        assert_eq!(result, serde_json::json!({}));
396    }
397
398    #[test]
399    fn test_parse_flags_rejects_positional() {
400        let schema = serde_json::json!({});
401        let result = parse_flags(&["42".into()], &schema);
402        assert!(result.is_err());
403        assert!(
404            result
405                .expect_err("should reject positional")
406                .contains("expected --flag")
407        );
408    }
409
410    #[test]
411    fn test_usage_from_schema() {
412        let schema = serde_json::json!({
413            "type": "object",
414            "properties": {
415                "id": {"type": "integer"},
416                "name": {"type": "string"}
417            }
418        });
419        let usage = usage_from_schema(&schema).expect("should produce usage string");
420        assert!(usage.contains("--id <integer>"));
421        assert!(usage.contains("--name <string>"));
422    }
423
424    #[test]
425    fn test_usage_from_empty_schema() {
426        assert!(usage_from_schema(&serde_json::json!({})).is_none());
427        assert!(
428            usage_from_schema(&serde_json::json!({"type": "object", "properties": {}})).is_none()
429        );
430    }
431
432    // -- HelpBuiltin tests --
433
434    fn build_help_test_tool() -> ScriptedTool {
435        ScriptedTool::builder("test_api")
436            .short_description("Test API")
437            .tool_fn(
438                ToolDef::new("get_user", "Fetch user by ID").with_schema(serde_json::json!({
439                    "type": "object",
440                    "properties": {
441                        "id": {"type": "integer"}
442                    }
443                })),
444                |_args: &ToolArgs| Ok("{\"id\":1}\n".to_string()),
445            )
446            .tool_fn(
447                ToolDef::new("list_orders", "List orders for user").with_schema(
448                    serde_json::json!({
449                        "type": "object",
450                        "properties": {
451                            "user_id": {"type": "integer"},
452                            "limit": {"type": "integer"}
453                        }
454                    }),
455                ),
456                |_args: &ToolArgs| Ok("[]\n".to_string()),
457            )
458            .build()
459    }
460
461    #[tokio::test]
462    async fn test_scripted_tool_execute_honors_timeout_ms() {
463        let tool = ScriptedTool::builder("timeout_test")
464            .async_tool_fn(
465                ToolDef::new("slow", "Slow async tool"),
466                |_args: ToolArgs| async move {
467                    tokio::time::sleep(Duration::from_secs(10)).await;
468                    Ok("done\n".to_string())
469                },
470            )
471            .build();
472        let start = bashkit::time::Instant::now();
473
474        let resp = tool
475            .execute(ToolRequest {
476                commands: "slow".to_string(),
477                timeout_ms: Some(50),
478            })
479            .await;
480
481        assert_eq!(resp.exit_code, 124);
482        assert_eq!(resp.error, Some("timeout".to_string()));
483        assert!(resp.stderr.contains("timed out"));
484        assert!(
485            start.elapsed() < Duration::from_secs(2),
486            "timeout_ms should abort before sleep completes"
487        );
488    }
489
490    #[tokio::test]
491    async fn test_scripted_tool_forwards_limits_and_output_caps() {
492        let tool = ScriptedTool::builder("bounded")
493            .limits(ExecutionLimits::new().max_commands(10).max_stdout_bytes(3))
494            .tool_fn(ToolDef::new("emit", "Emit output"), |_args| {
495                Ok("12345".to_string())
496            })
497            .build();
498
499        let response = tool.execute(ToolRequest::new("emit")).await;
500        assert_eq!(response.stdout, "123");
501        assert!(response.stdout_truncated);
502    }
503
504    #[tokio::test]
505    async fn test_scripted_tool_execute_with_status_honors_timeout_ms() {
506        let tool = ScriptedTool::builder("timeout_status_test")
507            .async_tool_fn(
508                ToolDef::new("slow", "Slow async tool"),
509                |_args: ToolArgs| async move {
510                    tokio::time::sleep(Duration::from_secs(10)).await;
511                    Ok("done\n".to_string())
512                },
513            )
514            .build();
515        let start = bashkit::time::Instant::now();
516
517        let resp = tool
518            .execute_with_status(
519                ToolRequest {
520                    commands: "slow".to_string(),
521                    timeout_ms: Some(50),
522                },
523                Box::new(|_| {}),
524            )
525            .await;
526
527        assert_eq!(resp.exit_code, 124);
528        assert_eq!(resp.error, Some("timeout".to_string()));
529        assert!(resp.stderr.contains("timed out"));
530        assert!(
531            start.elapsed() < Duration::from_secs(2),
532            "timeout_ms should abort before sleep completes"
533        );
534    }
535
536    #[tokio::test]
537    async fn test_help_list() {
538        let tool = build_help_test_tool();
539        let resp = tool
540            .execute(ToolRequest {
541                commands: "help --list".to_string(),
542                timeout_ms: None,
543            })
544            .await;
545        assert_eq!(resp.exit_code, 0);
546        assert!(resp.stdout.contains("get_user"));
547        assert!(resp.stdout.contains("Fetch user by ID"));
548        assert!(resp.stdout.contains("list_orders"));
549    }
550
551    #[tokio::test]
552    async fn test_help_tool_human_readable() {
553        let tool = build_help_test_tool();
554        let resp = tool
555            .execute(ToolRequest {
556                commands: "help get_user".to_string(),
557                timeout_ms: None,
558            })
559            .await;
560        assert_eq!(resp.exit_code, 0);
561        assert!(resp.stdout.contains("get_user - Fetch user by ID"));
562        assert!(resp.stdout.contains("--id <integer>"));
563    }
564
565    #[tokio::test]
566    async fn test_help_tool_json() {
567        let tool = build_help_test_tool();
568        let resp = tool
569            .execute(ToolRequest {
570                commands: "help get_user --json".to_string(),
571                timeout_ms: None,
572            })
573            .await;
574        assert_eq!(resp.exit_code, 0);
575        let parsed: serde_json::Value =
576            serde_json::from_str(resp.stdout.trim()).expect("should be valid JSON");
577        assert_eq!(parsed["name"], "get_user");
578        assert_eq!(parsed["description"], "Fetch user by ID");
579        assert!(parsed["input_schema"]["properties"]["id"].is_object());
580    }
581
582    #[tokio::test]
583    async fn test_help_unknown_tool() {
584        let tool = build_help_test_tool();
585        let resp = tool
586            .execute(ToolRequest {
587                commands: "help nonexistent".to_string(),
588                timeout_ms: None,
589            })
590            .await;
591        assert_ne!(resp.exit_code, 0);
592        assert!(resp.stderr.contains("unknown tool"));
593    }
594
595    #[tokio::test]
596    async fn test_help_no_args_lists_all() {
597        let tool = build_help_test_tool();
598        let resp = tool
599            .execute(ToolRequest {
600                commands: "help".to_string(),
601                timeout_ms: None,
602            })
603            .await;
604        assert_eq!(resp.exit_code, 0);
605        assert!(resp.stdout.contains("get_user"));
606        assert!(resp.stdout.contains("list_orders"));
607    }
608
609    #[tokio::test]
610    async fn test_help_json_pipe_jq() {
611        let tool = build_help_test_tool();
612        let resp = tool
613            .execute(ToolRequest {
614                commands: "help get_user --json | jq -r '.name'".to_string(),
615                timeout_ms: None,
616            })
617            .await;
618        assert_eq!(resp.exit_code, 0);
619        assert_eq!(resp.stdout.trim(), "get_user");
620    }
621
622    #[tokio::test]
623    async fn test_compact_prompt_omits_usage() {
624        let tool = ScriptedTool::builder("compact_test")
625            .compact_prompt(true)
626            .tool_fn(
627                ToolDef::new("get_user", "Fetch user").with_schema(serde_json::json!({
628                    "type": "object",
629                    "properties": { "id": {"type": "integer"} }
630                })),
631                |_args: &ToolArgs| Ok("ok\n".to_string()),
632            )
633            .build();
634        let sp = tool.system_prompt();
635        assert!(sp.contains("help/discover"));
636        assert!(!sp.contains("Usage:"));
637    }
638
639    #[tokio::test]
640    async fn test_non_compact_prompt_has_usage() {
641        let tool = ScriptedTool::builder("full_test")
642            .tool_fn(
643                ToolDef::new("get_user", "Fetch user").with_schema(serde_json::json!({
644                    "type": "object",
645                    "properties": { "id": {"type": "integer"} }
646                })),
647                |_args: &ToolArgs| Ok("ok\n".to_string()),
648            )
649            .build();
650        let sp = tool.system_prompt();
651        assert!(sp.contains("--id <integer>"));
652    }
653
654    #[tokio::test]
655    async fn test_error_uses_display_not_debug() {
656        use super::ScriptedTool;
657        use crate::ToolDef;
658        use bashkit::tool::Tool;
659
660        let tool = ScriptedTool::builder("test")
661            .short_description("test")
662            .tool_fn(ToolDef::new("fail", "Always fails"), |_args: &ToolArgs| {
663                Err("service error".to_string())
664            })
665            .build();
666        let req = ToolRequest {
667            commands: "fail".into(),
668            timeout_ms: None,
669        };
670        let resp = tool.execute(req).await;
671        // Error messages use Display format, not Debug, to avoid leaking internals
672        if let Some(ref err) = resp.error {
673            assert!(
674                !err.contains("Execution("),
675                "error should use Display not Debug: {err}",
676            );
677        }
678    }
679
680    // -- DiscoverBuiltin tests --
681
682    fn build_discover_test_tool() -> ScriptedTool {
683        ScriptedTool::builder("big_api")
684            .short_description("Big API")
685            .tool_fn(
686                ToolDef::new("create_charge", "Create a payment charge")
687                    .with_category("payments")
688                    .with_tags(&["billing", "write"]),
689                |_args: &ToolArgs| Ok("ok\n".to_string()),
690            )
691            .tool_fn(
692                ToolDef::new("refund", "Issue a refund")
693                    .with_category("payments")
694                    .with_tags(&["billing", "write"]),
695                |_args: &ToolArgs| Ok("ok\n".to_string()),
696            )
697            .tool_fn(
698                ToolDef::new("get_user", "Fetch user by ID")
699                    .with_category("users")
700                    .with_tags(&["read"]),
701                |_args: &ToolArgs| Ok("ok\n".to_string()),
702            )
703            .tool_fn(
704                ToolDef::new("delete_user", "Delete a user account")
705                    .with_category("users")
706                    .with_tags(&["admin", "write"]),
707                |_args: &ToolArgs| Ok("ok\n".to_string()),
708            )
709            .tool_fn(
710                ToolDef::new("get_inventory", "Check inventory levels").with_category("inventory"),
711                |_args: &ToolArgs| Ok("ok\n".to_string()),
712            )
713            .build()
714    }
715
716    #[tokio::test]
717    async fn test_discover_categories() {
718        let tool = build_discover_test_tool();
719        let resp = tool
720            .execute(ToolRequest {
721                commands: "discover --categories".to_string(),
722                timeout_ms: None,
723            })
724            .await;
725        assert_eq!(resp.exit_code, 0);
726        assert!(resp.stdout.contains("payments (2 tools)"));
727        assert!(resp.stdout.contains("users (2 tools)"));
728        assert!(resp.stdout.contains("inventory (1 tool)"));
729    }
730
731    #[tokio::test]
732    async fn test_discover_category_filter() {
733        let tool = build_discover_test_tool();
734        let resp = tool
735            .execute(ToolRequest {
736                commands: "discover --category payments".to_string(),
737                timeout_ms: None,
738            })
739            .await;
740        assert_eq!(resp.exit_code, 0);
741        assert!(resp.stdout.contains("create_charge"));
742        assert!(resp.stdout.contains("refund"));
743        assert!(!resp.stdout.contains("get_user"));
744    }
745
746    #[tokio::test]
747    async fn test_discover_tag_filter() {
748        let tool = build_discover_test_tool();
749        let resp = tool
750            .execute(ToolRequest {
751                commands: "discover --tag admin".to_string(),
752                timeout_ms: None,
753            })
754            .await;
755        assert_eq!(resp.exit_code, 0);
756        assert!(resp.stdout.contains("delete_user"));
757        assert!(!resp.stdout.contains("create_charge"));
758    }
759
760    #[tokio::test]
761    async fn test_discover_search() {
762        let tool = build_discover_test_tool();
763        let resp = tool
764            .execute(ToolRequest {
765                commands: "discover --search user".to_string(),
766                timeout_ms: None,
767            })
768            .await;
769        assert_eq!(resp.exit_code, 0);
770        assert!(resp.stdout.contains("get_user"));
771        assert!(resp.stdout.contains("delete_user"));
772        assert!(!resp.stdout.contains("create_charge"));
773    }
774
775    #[tokio::test]
776    async fn test_discover_search_case_insensitive() {
777        let tool = build_discover_test_tool();
778        let resp = tool
779            .execute(ToolRequest {
780                commands: "discover --search REFUND".to_string(),
781                timeout_ms: None,
782            })
783            .await;
784        assert_eq!(resp.exit_code, 0);
785        assert!(resp.stdout.contains("refund"));
786    }
787
788    #[tokio::test]
789    async fn test_discover_categories_json() {
790        let tool = build_discover_test_tool();
791        let resp = tool
792            .execute(ToolRequest {
793                commands: "discover --categories --json".to_string(),
794                timeout_ms: None,
795            })
796            .await;
797        assert_eq!(resp.exit_code, 0);
798        let arr: Vec<serde_json::Value> =
799            serde_json::from_str(resp.stdout.trim()).expect("valid JSON");
800        assert!(
801            arr.iter()
802                .any(|v| v["category"] == "payments" && v["count"] == 2)
803        );
804    }
805
806    #[tokio::test]
807    async fn test_discover_category_json() {
808        let tool = build_discover_test_tool();
809        let resp = tool
810            .execute(ToolRequest {
811                commands: "discover --category payments --json".to_string(),
812                timeout_ms: None,
813            })
814            .await;
815        assert_eq!(resp.exit_code, 0);
816        let arr: Vec<serde_json::Value> =
817            serde_json::from_str(resp.stdout.trim()).expect("valid JSON");
818        assert_eq!(arr.len(), 2);
819        assert!(arr.iter().any(|v| v["name"] == "create_charge"));
820    }
821
822    #[tokio::test]
823    async fn test_discover_no_args_shows_usage() {
824        let tool = build_discover_test_tool();
825        let resp = tool
826            .execute(ToolRequest {
827                commands: "discover".to_string(),
828                timeout_ms: None,
829            })
830            .await;
831        assert_ne!(resp.exit_code, 0);
832        assert!(resp.stderr.contains("usage:"));
833    }
834
835    #[tokio::test]
836    async fn test_discover_tag_json() {
837        let tool = build_discover_test_tool();
838        let resp = tool
839            .execute(ToolRequest {
840                commands: "discover --tag billing --json".to_string(),
841                timeout_ms: None,
842            })
843            .await;
844        assert_eq!(resp.exit_code, 0);
845        let arr: Vec<serde_json::Value> =
846            serde_json::from_str(resp.stdout.trim()).expect("valid JSON");
847        assert_eq!(arr.len(), 2);
848        assert!(arr.iter().all(|v| {
849            v["tags"]
850                .as_array()
851                .expect("tags array")
852                .contains(&serde_json::json!("billing"))
853        }));
854    }
855
856    #[tokio::test]
857    async fn test_tooldef_with_tags_and_category() {
858        let def = ToolDef::new("test", "A test tool")
859            .with_tags(&["admin", "billing"])
860            .with_category("payments");
861        assert_eq!(def.tags, vec!["admin", "billing"]);
862        assert_eq!(def.category.as_deref(), Some("payments"));
863    }
864
865    // THREAT[TM-INF-030]: Callback error sanitization tests
866
867    #[tokio::test]
868    async fn test_callback_error_sanitized_by_default() {
869        let tool = ScriptedTool::builder("api")
870            .tool_fn(ToolDef::new("fail", "Always fails"), |_args: &ToolArgs| {
871                Err("connection failed: postgres://admin:secret@internal-db:5432/prod".into())
872            })
873            .build();
874        let resp = tool
875            .execute(ToolRequest {
876                commands: "fail".to_string(),
877                timeout_ms: None,
878            })
879            .await;
880        assert_ne!(resp.exit_code, 0);
881        // Internal details must NOT appear in output
882        assert!(
883            !resp.stderr.contains("postgres://"),
884            "internal details leaked: {}",
885            resp.stderr
886        );
887        assert!(resp.stderr.contains("callback failed"));
888    }
889
890    #[tokio::test]
891    async fn test_callback_error_unsanitized_when_disabled() {
892        let tool = ScriptedTool::builder("api")
893            .sanitize_errors(false)
894            .tool_fn(ToolDef::new("fail", "Always fails"), |_args: &ToolArgs| {
895                Err("connection failed: postgres://admin:secret@internal-db:5432/prod".into())
896            })
897            .build();
898        let resp = tool
899            .execute(ToolRequest {
900                commands: "fail".to_string(),
901                timeout_ms: None,
902            })
903            .await;
904        assert_ne!(resp.exit_code, 0);
905        // With sanitization disabled, full error should appear
906        assert!(resp.stderr.contains("postgres://"));
907    }
908}
909
910#[cfg(test)]
911mod custom_builtin_tests {
912    use crate::{ScriptedTool, ToolArgs, ToolDef};
913    use async_trait::async_trait;
914    use bashkit::{BashkitContext, ClapBuiltin, Error, ExecResult, Tool};
915    use bashkit::{Builtin, BuiltinContext as Context};
916    use clap::Parser;
917
918    /// Echoes raw argv so tests can assert the builtin saw it unparsed.
919    struct RawArgv;
920
921    #[async_trait]
922    impl Builtin for RawArgv {
923        async fn execute(&self, ctx: Context<'_>) -> bashkit::Result<ExecResult> {
924            Ok(ExecResult::ok(format!("argv:[{}]\n", ctx.args.join("|"))))
925        }
926    }
927
928    /// Would read a file if the logic-only filesystem allowed it.
929    struct ReadsFile;
930
931    #[async_trait]
932    impl Builtin for ReadsFile {
933        async fn execute(&self, ctx: Context<'_>) -> bashkit::Result<ExecResult> {
934            match ctx.fs.read_file(std::path::Path::new("/etc/passwd")).await {
935                Ok(_) => Ok(ExecResult::ok("read ok\n")),
936                Err(e) => Ok(ExecResult::err(format!("denied: {e}\n"), 1)),
937            }
938        }
939    }
940
941    struct LeakyBuiltin;
942
943    #[async_trait]
944    impl Builtin for LeakyBuiltin {
945        async fn execute(&self, _ctx: Context<'_>) -> bashkit::Result<ExecResult> {
946            Err(Error::Network(
947                "postgres://admin:secret@internal-db/prod".into(),
948            ))
949        }
950    }
951
952    #[derive(Parser)]
953    #[command(name = "leaky_clap")]
954    struct LeakyArgs {}
955
956    struct LeakyClapBuiltin;
957
958    #[async_trait]
959    impl ClapBuiltin for LeakyClapBuiltin {
960        type Args = LeakyArgs;
961
962        async fn execute_clap(
963            &self,
964            _args: Self::Args,
965            _ctx: &mut BashkitContext<'_>,
966        ) -> bashkit::Result<()> {
967            Err(Error::Execution(
968                "SDK diagnostic: postgres://admin:secret@internal-db/prod".into(),
969            ))
970        }
971    }
972
973    async fn run(tool: &ScriptedTool, script: &str) -> (String, String, i64) {
974        let output = tool
975            .execution(serde_json::json!({ "commands": script }))
976            .expect("execution")
977            .execute()
978            .await
979            .expect("execute");
980        (
981            output.result["stdout"].as_str().unwrap_or_default().into(),
982            output.result["stderr"].as_str().unwrap_or_default().into(),
983            output.result["exit_code"].as_i64().unwrap_or_default(),
984        )
985    }
986
987    fn tool_with_raw_argv() -> ScriptedTool {
988        ScriptedTool::builder("api")
989            .builtin("raw", Box::new(RawArgv))
990            .tool_fn(
991                ToolDef::new("greet", "Greet").with_schema(serde_json::json!({
992                    "type": "object",
993                    "properties": { "name": {"type": "string"} }
994                })),
995                |args: &ToolArgs| Ok(format!("hi {}\n", args.param_str("name").unwrap_or("?"))),
996            )
997            .build()
998    }
999
1000    #[tokio::test]
1001    async fn custom_builtin_receives_raw_argv() {
1002        let tool = tool_with_raw_argv();
1003        // Positionals and short flags: shapes `parse_flags` rejects outright.
1004        let (stdout, _, code) = run(&tool, "raw pos1 -s --long=v --").await;
1005        assert_eq!(code, 0);
1006        assert_eq!(stdout, "argv:[pos1|-s|--long=v|--]\n");
1007    }
1008
1009    #[tokio::test]
1010    async fn custom_builtin_coexists_with_tool_commands() {
1011        let tool = tool_with_raw_argv();
1012        let (stdout, _, code) = run(&tool, "raw a; greet --name bob").await;
1013        assert_eq!(code, 0);
1014        assert_eq!(stdout, "argv:[a]\nhi bob\n");
1015    }
1016
1017    #[tokio::test]
1018    async fn custom_builtin_reused_across_executions() {
1019        let tool = tool_with_raw_argv();
1020        for _ in 0..3 {
1021            let (stdout, _, code) = run(&tool, "raw x").await;
1022            assert_eq!(code, 0);
1023            assert_eq!(stdout, "argv:[x]\n");
1024        }
1025    }
1026
1027    #[tokio::test]
1028    async fn custom_builtin_cannot_shadow_a_tool_command() {
1029        // `greet` is registered both ways; the ToolDef command must win.
1030        let tool = ScriptedTool::builder("api")
1031            .builtin("greet", Box::new(RawArgv))
1032            .tool_fn(
1033                ToolDef::new("greet", "Greet").with_schema(serde_json::json!({
1034                    "type": "object",
1035                    "properties": { "name": {"type": "string"} }
1036                })),
1037                |args: &ToolArgs| Ok(format!("hi {}\n", args.param_str("name").unwrap_or("?"))),
1038            )
1039            .build();
1040        let (stdout, _, _) = run(&tool, "greet --name bob").await;
1041        assert_eq!(stdout, "hi bob\n");
1042    }
1043
1044    #[tokio::test]
1045    async fn custom_builtin_cannot_shadow_help_or_discover() {
1046        let tool = ScriptedTool::builder("api")
1047            .builtin("help", Box::new(RawArgv))
1048            .builtin("discover", Box::new(RawArgv))
1049            .tool_fn(
1050                ToolDef::new("greet", "Greet").with_schema(serde_json::json!({})),
1051                |_: &ToolArgs| Ok("hi\n".to_string()),
1052            )
1053            .build();
1054        let (stdout, _, _) = run(&tool, "help --list").await;
1055        assert!(!stdout.starts_with("argv:"), "help was shadowed: {stdout}");
1056        assert!(stdout.contains("greet"), "got: {stdout}");
1057    }
1058
1059    #[tokio::test]
1060    async fn custom_builtin_still_has_no_filesystem() {
1061        // Registering a builtin must not widen the logic-only posture.
1062        let tool = ScriptedTool::builder("api")
1063            .builtin("readfile", Box::new(ReadsFile))
1064            .build();
1065        let (stdout, _, code) = run(&tool, "readfile").await;
1066        assert_ne!(code, 0, "filesystem must stay disabled, got: {stdout}");
1067    }
1068
1069    #[tokio::test]
1070    async fn custom_builtin_errors_are_sanitized_by_default() {
1071        for (name, builtin) in [
1072            ("leaky", Box::new(LeakyBuiltin) as Box<dyn Builtin>),
1073            ("leaky_clap", Box::new(LeakyClapBuiltin) as Box<dyn Builtin>),
1074        ] {
1075            let tool = ScriptedTool::builder("api").builtin(name, builtin).build();
1076            let (_, stderr, code) = run(&tool, name).await;
1077
1078            assert_ne!(code, 0);
1079            assert!(!stderr.contains("postgres://"), "secret leaked: {stderr}");
1080            assert!(stderr.contains("callback failed"), "got: {stderr}");
1081        }
1082    }
1083
1084    #[tokio::test]
1085    async fn custom_builtin_errors_can_be_unsanitized_explicitly() {
1086        for (name, builtin) in [
1087            ("leaky", Box::new(LeakyBuiltin) as Box<dyn Builtin>),
1088            ("leaky_clap", Box::new(LeakyClapBuiltin) as Box<dyn Builtin>),
1089        ] {
1090            let tool = ScriptedTool::builder("api")
1091                .sanitize_errors(false)
1092                .builtin(name, builtin)
1093                .build();
1094            let (_, stderr, code) = run(&tool, name).await;
1095
1096            assert_ne!(code, 0);
1097            assert!(stderr.contains("postgres://"), "got: {stderr}");
1098        }
1099    }
1100
1101    #[tokio::test]
1102    async fn closed_schema_rejects_typo_through_the_shell() {
1103        let tool = ScriptedTool::builder("api")
1104            .tool_fn(
1105                ToolDef::new("list", "List").with_schema(serde_json::json!({
1106                    "type": "object",
1107                    "properties": { "limit": {"type": "integer"} },
1108                    "additionalProperties": false
1109                })),
1110                |args: &ToolArgs| Ok(format!("limit={}\n", args.param_i64("limit").unwrap_or(0))),
1111            )
1112            .build();
1113
1114        let (_, stderr, code) = run(&tool, "list --limti 5").await;
1115        assert_ne!(code, 0);
1116        assert!(stderr.contains("unknown flag: --limti"), "got: {stderr}");
1117
1118        let (stdout, _, code) = run(&tool, "list --limit 5").await;
1119        assert_eq!(code, 0);
1120        assert_eq!(stdout, "limit=5\n");
1121    }
1122}