Skip to main content

zeroclaw_runtime/tools/
skill_tool.rs

1//! Shell-based tool derived from a skill's `[[tools]]` section.
2//!
3//! Each `SkillTool` with `kind = "shell"` or `kind = "script"` is converted
4//! into a `SkillShellTool` that implements the `Tool` trait. The tool name is
5//! prefixed with the skill name (e.g. `my_skill__run_lint`) to avoid collisions
6//! with built-in tools. The `__` separator matches the MCP server prefix
7//! convention and keeps names valid under OpenAI-compatible function-name
8//! rules (`^[a-zA-Z0-9_-]+$`), which reject `.`.
9
10use crate::security::SecurityPolicy;
11use async_trait::async_trait;
12use std::collections::HashMap;
13use std::sync::Arc;
14use std::time::Duration;
15use zeroclaw_api::tool::{Tool, ToolResult};
16
17/// Maximum execution time for a skill shell command (seconds).
18const SKILL_SHELL_TIMEOUT_SECS: u64 = 60;
19/// Maximum output size in bytes (1 MB).
20const MAX_OUTPUT_BYTES: usize = 1_048_576;
21
22/// A tool derived from a skill's `[[tools]]` section that executes shell commands.
23pub struct SkillShellTool {
24    tool_name: String,
25    tool_description: String,
26    command_template: String,
27    args: HashMap<String, String>,
28    security: Arc<SecurityPolicy>,
29}
30
31impl SkillShellTool {
32    /// Create a new skill shell tool.
33    ///
34    /// The tool name is prefixed with the skill name (`skill_name__tool_name`)
35    /// to prevent collisions with built-in tools.
36    pub fn new(
37        skill_name: &str,
38        tool: &crate::skills::SkillTool,
39        security: Arc<SecurityPolicy>,
40    ) -> Self {
41        Self {
42            tool_name: format!("{}__{}", skill_name, tool.name),
43            tool_description: tool.description.clone(),
44            command_template: tool.command.clone(),
45            args: tool.args.clone(),
46            security,
47        }
48    }
49
50    fn build_parameters_schema(&self) -> serde_json::Value {
51        let mut properties = serde_json::Map::new();
52        let mut required = Vec::new();
53
54        for (name, description) in &self.args {
55            properties.insert(
56                name.clone(),
57                serde_json::json!({
58                    "type": "string",
59                    "description": description
60                }),
61            );
62            required.push(serde_json::Value::String(name.clone()));
63        }
64
65        serde_json::json!({
66            "type": "object",
67            "properties": properties,
68            "required": required
69        })
70    }
71
72    /// Substitute `{{arg_name}}` placeholders in the command template with
73    /// the provided argument values. Unknown placeholders are left as-is.
74    fn substitute_args(&self, args: &serde_json::Value) -> String {
75        let mut command = self.command_template.clone();
76        if let Some(obj) = args.as_object() {
77            for (key, value) in obj {
78                let placeholder = format!("{{{{{}}}}}", key);
79                let replacement = value.as_str().unwrap_or_default();
80                command = command.replace(&placeholder, replacement);
81            }
82        }
83        command
84    }
85}
86
87#[async_trait]
88impl Tool for SkillShellTool {
89    fn name(&self) -> &str {
90        &self.tool_name
91    }
92
93    fn description(&self) -> &str {
94        &self.tool_description
95    }
96
97    fn parameters_schema(&self) -> serde_json::Value {
98        self.build_parameters_schema()
99    }
100
101    async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
102        let command = self.substitute_args(&args);
103
104        // Rate limiting is applied by the RateLimitedTool wrapper at
105        // registration time (see zeroclaw-runtime::tools::mod). The
106        // PathGuardedTool wrapper cannot inspect the substituted command
107        // built by substitute_args, so the forbidden_path_argument check
108        // below remains tool-local.
109
110        // Security validation — always requires explicit approval (approved=true)
111        // since skill tools are user-defined and should be treated as medium-risk.
112        match self.security.validate_command_execution(&command, true) {
113            Ok(_) => {}
114            Err(reason) => {
115                return Ok(ToolResult {
116                    success: false,
117                    output: String::new(),
118                    error: Some(reason),
119                });
120            }
121        }
122
123        if let Some(path) = self.security.forbidden_path_argument(&command) {
124            return Ok(ToolResult {
125                success: false,
126                output: String::new(),
127                error: Some(format!("Path blocked by security policy: {path}")),
128            });
129        }
130
131        // Build and execute the command
132        let mut cmd = tokio::process::Command::new("sh");
133        cmd.arg("-c").arg(&command);
134        cmd.current_dir(&self.security.workspace_dir);
135        cmd.env_clear();
136
137        // Only pass safe environment variables
138        for var in &[
139            "PATH", "HOME", "TERM", "LANG", "LC_ALL", "USER", "SHELL", "TMPDIR",
140        ] {
141            if let Ok(val) = std::env::var(var) {
142                cmd.env(var, val);
143            }
144        }
145
146        let result =
147            tokio::time::timeout(Duration::from_secs(SKILL_SHELL_TIMEOUT_SECS), cmd.output()).await;
148
149        match result {
150            Ok(Ok(output)) => {
151                let mut stdout = String::from_utf8_lossy(&output.stdout).to_string();
152                let mut stderr = String::from_utf8_lossy(&output.stderr).to_string();
153
154                if stdout.len() > MAX_OUTPUT_BYTES {
155                    let mut b = MAX_OUTPUT_BYTES.min(stdout.len());
156                    while b > 0 && !stdout.is_char_boundary(b) {
157                        b -= 1;
158                    }
159                    stdout.truncate(b);
160                    stdout.push_str("\n... [output truncated at 1MB]");
161                }
162                if stderr.len() > MAX_OUTPUT_BYTES {
163                    let mut b = MAX_OUTPUT_BYTES.min(stderr.len());
164                    while b > 0 && !stderr.is_char_boundary(b) {
165                        b -= 1;
166                    }
167                    stderr.truncate(b);
168                    stderr.push_str("\n... [stderr truncated at 1MB]");
169                }
170
171                Ok(ToolResult {
172                    success: output.status.success(),
173                    output: stdout,
174                    error: if stderr.is_empty() {
175                        None
176                    } else {
177                        Some(stderr)
178                    },
179                })
180            }
181            Ok(Err(e)) => Ok(ToolResult {
182                success: false,
183                output: String::new(),
184                error: Some(format!("Failed to execute command: {e}")),
185            }),
186            Err(_) => Ok(ToolResult {
187                success: false,
188                output: String::new(),
189                error: Some(format!(
190                    "Command timed out after {SKILL_SHELL_TIMEOUT_SECS}s and was killed"
191                )),
192            }),
193        }
194    }
195}
196
197// ─── Builtin / MCP delegation tool ───────────────────────────────────────────
198
199/// A skill tool that delegates execution to another tool resolved from the
200/// resolution registry — either a built-in (`kind = "builtin"`) or an MCP tool
201/// (`kind = "mcp"`). This is the skill-scoped tool elevation mechanism: a
202/// policy blocking `shell` by name (or deferred MCP tools hidden from the
203/// model) does not block `my_skill__use_shell`, because the wrapper is
204/// registered under the prefixed name `{skill}__{tool}` and delegates to the
205/// resolved target.
206///
207/// `locked_args` are arguments fixed by the manifest. They are applied **on top
208/// of** the caller-supplied args (the caller cannot override them) and are
209/// stripped from the advertised parameter schema, so the model can neither see
210/// nor change them. This is what scopes a delegated tool — e.g.
211/// `target = "composio"` + `locked_args = { action_name = "TEXT_TO_PDF" }`
212/// exposes exactly one action, and `target = "images__generate"` exposes a
213/// single MCP capability.
214pub struct SkillBuiltinTool {
215    tool_name: String,
216    tool_description: String,
217    target_tool: Arc<dyn zeroclaw_api::tool::Tool>,
218    locked_args: serde_json::Map<String, serde_json::Value>,
219    /// Target schema with the locked keys removed (precomputed at construction).
220    advertised_schema: serde_json::Value,
221}
222
223impl SkillBuiltinTool {
224    /// Create a new skill elevation tool delegating to `target_tool`.
225    ///
226    /// `target_tool` is the resolved built-in or MCP tool (looked up from the
227    /// resolution registry at registration time). `locked_args` are fixed by
228    /// the manifest: applied over caller args (non-overridable) and hidden from
229    /// the advertised schema.
230    pub fn new(
231        skill_name: &str,
232        tool: &crate::skills::SkillTool,
233        target_tool: Arc<dyn zeroclaw_api::tool::Tool>,
234        locked_args: HashMap<String, String>,
235    ) -> Self {
236        let locked: serde_json::Map<String, serde_json::Value> = locked_args
237            .into_iter()
238            .map(|(k, v)| (k, serde_json::Value::String(v)))
239            .collect();
240        let advertised_schema = narrow_schema(target_tool.parameters_schema(), &locked);
241        Self {
242            tool_name: format!("{}__{}", skill_name, tool.name),
243            tool_description: tool.description.clone(),
244            target_tool,
245            locked_args: locked,
246            advertised_schema,
247        }
248    }
249}
250
251/// Merge caller args with manifest `locked` args. Locked args ALWAYS win — the
252/// caller cannot override a scope key — but the caller may add other keys.
253fn merge_locked_args(
254    locked: &serde_json::Map<String, serde_json::Value>,
255    caller: serde_json::Value,
256) -> serde_json::Value {
257    if locked.is_empty() {
258        return caller;
259    }
260    let mut merged = match caller {
261        serde_json::Value::Object(map) => map,
262        _ => serde_json::Map::new(),
263    };
264    for (k, v) in locked {
265        merged.insert(k.clone(), v.clone());
266    }
267    serde_json::Value::Object(merged)
268}
269
270/// Remove `locked` keys from an advertised JSON-schema object so the model
271/// neither sees nor tries to set keys the manifest fixes. Non-object schemas
272/// (or those without `properties`) pass through unchanged.
273fn narrow_schema(
274    schema: serde_json::Value,
275    locked: &serde_json::Map<String, serde_json::Value>,
276) -> serde_json::Value {
277    if locked.is_empty() {
278        return schema;
279    }
280    let serde_json::Value::Object(mut obj) = schema else {
281        return schema;
282    };
283    if let Some(serde_json::Value::Object(props)) = obj.get_mut("properties") {
284        for k in locked.keys() {
285            props.remove(k);
286        }
287    }
288    if let Some(serde_json::Value::Array(required)) = obj.get_mut("required") {
289        required.retain(|v| v.as_str().is_none_or(|s| !locked.contains_key(s)));
290    }
291    serde_json::Value::Object(obj)
292}
293
294#[async_trait]
295impl Tool for SkillBuiltinTool {
296    fn name(&self) -> &str {
297        &self.tool_name
298    }
299
300    fn description(&self) -> &str {
301        &self.tool_description
302    }
303
304    fn parameters_schema(&self) -> serde_json::Value {
305        self.advertised_schema.clone()
306    }
307
308    async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
309        // Audit: elevated skill tools delegate to a target that may be blocked
310        // by SecurityPolicy or hidden from the model. Record every invocation
311        // at INFO with the delegation target and the locked scope keys.
312        ::zeroclaw_log::record!(
313            INFO,
314            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Invoke)
315                .with_category(::zeroclaw_log::EventCategory::Tool)
316                .with_attrs(::serde_json::json!({
317                    "skill_tool": self.tool_name,
318                    "delegates_to": self.target_tool.name(),
319                    "locked_keys": self.locked_args.keys().collect::<Vec<_>>(),
320                })),
321            "skill-scoped elevated tool invoked"
322        );
323        let merged = merge_locked_args(&self.locked_args, args);
324        self.target_tool.execute(merged).await
325    }
326}
327
328#[cfg(test)]
329mod tests {
330    use super::*;
331    use crate::security::{AutonomyLevel, SecurityPolicy};
332    use crate::skills::SkillTool;
333
334    fn test_security() -> Arc<SecurityPolicy> {
335        Arc::new(SecurityPolicy {
336            autonomy: AutonomyLevel::Full,
337            workspace_dir: std::env::temp_dir(),
338            ..SecurityPolicy::default()
339        })
340    }
341
342    fn sample_skill_tool() -> SkillTool {
343        let mut args = HashMap::new();
344        args.insert("file".to_string(), "The file to lint".to_string());
345        args.insert(
346            "format".to_string(),
347            "Output format (json|text)".to_string(),
348        );
349
350        SkillTool {
351            name: "run_lint".to_string(),
352            description: "Run the linter on a file".to_string(),
353            kind: "shell".to_string(),
354            command: "lint --file {{file}} --format {{format}}".to_string(),
355            args,
356            target: None,
357            locked_args: HashMap::new(),
358        }
359    }
360
361    #[test]
362    fn skill_shell_tool_name_is_prefixed() {
363        let tool = SkillShellTool::new("my_skill", &sample_skill_tool(), test_security());
364        assert_eq!(tool.name(), "my_skill__run_lint");
365    }
366
367    #[test]
368    fn skill_shell_tool_description() {
369        let tool = SkillShellTool::new("my_skill", &sample_skill_tool(), test_security());
370        assert_eq!(tool.description(), "Run the linter on a file");
371    }
372
373    #[test]
374    fn skill_shell_tool_parameters_schema() {
375        let tool = SkillShellTool::new("my_skill", &sample_skill_tool(), test_security());
376        let schema = tool.parameters_schema();
377
378        assert_eq!(schema["type"], "object");
379        assert!(schema["properties"]["file"].is_object());
380        assert_eq!(schema["properties"]["file"]["type"], "string");
381        assert!(schema["properties"]["format"].is_object());
382
383        let required = schema["required"]
384            .as_array()
385            .expect("required should be array");
386        assert_eq!(required.len(), 2);
387    }
388
389    #[test]
390    fn skill_shell_tool_substitute_args() {
391        let tool = SkillShellTool::new("my_skill", &sample_skill_tool(), test_security());
392        let result = tool.substitute_args(&serde_json::json!({
393            "file": "src/main.rs",
394            "format": "json"
395        }));
396        assert_eq!(result, "lint --file src/main.rs --format json");
397    }
398
399    #[test]
400    fn skill_shell_tool_substitute_missing_arg() {
401        let tool = SkillShellTool::new("my_skill", &sample_skill_tool(), test_security());
402        let result = tool.substitute_args(&serde_json::json!({"file": "test.rs"}));
403        // Missing {{format}} placeholder stays in the command
404        assert!(result.contains("{{format}}"));
405        assert!(result.contains("test.rs"));
406    }
407
408    #[test]
409    fn skill_shell_tool_empty_args_schema() {
410        let st = SkillTool {
411            name: "simple".to_string(),
412            description: "Simple tool".to_string(),
413            kind: "shell".to_string(),
414            command: "echo hello".to_string(),
415            args: HashMap::new(),
416            target: None,
417            locked_args: HashMap::new(),
418        };
419        let tool = SkillShellTool::new("s", &st, test_security());
420        let schema = tool.parameters_schema();
421        assert_eq!(schema["type"], "object");
422        assert!(schema["properties"].as_object().unwrap().is_empty());
423        assert!(schema["required"].as_array().unwrap().is_empty());
424    }
425
426    #[tokio::test]
427    async fn skill_shell_tool_executes_echo() {
428        let st = SkillTool {
429            name: "hello".to_string(),
430            description: "Say hello".to_string(),
431            kind: "shell".to_string(),
432            command: "echo hello-skill".to_string(),
433            args: HashMap::new(),
434            target: None,
435            locked_args: HashMap::new(),
436        };
437        let tool = SkillShellTool::new("test", &st, test_security());
438        let result = tool.execute(serde_json::json!({})).await.unwrap();
439        assert!(result.success);
440        assert!(result.output.contains("hello-skill"));
441    }
442
443    #[test]
444    fn skill_shell_tool_spec_roundtrip() {
445        let tool = SkillShellTool::new("my_skill", &sample_skill_tool(), test_security());
446        let spec = tool.spec();
447        assert_eq!(spec.name, "my_skill__run_lint");
448        assert_eq!(spec.description, "Run the linter on a file");
449        assert_eq!(spec.parameters["type"], "object");
450    }
451
452    // ─── SkillBuiltinTool tests ──────────────────────────────────────────────
453
454    /// Minimal mock tool for testing builtin delegation.
455    struct MockBuiltinTool {
456        name: String,
457    }
458
459    impl MockBuiltinTool {
460        fn new(name: &str) -> Self {
461            Self {
462                name: name.to_string(),
463            }
464        }
465    }
466
467    impl ::zeroclaw_api::attribution::Attributable for MockBuiltinTool {
468        fn role(&self) -> ::zeroclaw_api::attribution::Role {
469            ::zeroclaw_api::attribution::Role::Tool(::zeroclaw_api::attribution::ToolKind::Plugin)
470        }
471        fn alias(&self) -> &str {
472            &self.name
473        }
474    }
475
476    #[async_trait]
477    impl Tool for MockBuiltinTool {
478        fn name(&self) -> &str {
479            &self.name
480        }
481        fn description(&self) -> &str {
482            "Mock builtin for testing"
483        }
484        fn parameters_schema(&self) -> serde_json::Value {
485            serde_json::json!({
486                "type": "object",
487                "properties": {
488                    "input": { "type": "string" }
489                },
490                "required": ["input"]
491            })
492        }
493        async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
494            let input = args.get("input").and_then(|v| v.as_str()).unwrap_or("none");
495            Ok(ToolResult {
496                success: true,
497                output: format!("mock_result:{input}"),
498                error: None,
499            })
500        }
501    }
502
503    fn sample_builtin_skill_tool() -> SkillTool {
504        SkillTool {
505            name: "use_shell".to_string(),
506            description: "Elevated shell access via skill".to_string(),
507            kind: "builtin".to_string(),
508            command: String::new(),
509            args: HashMap::new(),
510            target: Some("shell".to_string()),
511            locked_args: HashMap::new(),
512        }
513    }
514
515    #[test]
516    fn skill_builtin_tool_name_is_prefixed() {
517        let target: Arc<dyn Tool> = Arc::new(MockBuiltinTool::new("shell"));
518        let tool = SkillBuiltinTool::new(
519            "my_skill",
520            &sample_builtin_skill_tool(),
521            target,
522            HashMap::new(),
523        );
524        assert_eq!(tool.name(), "my_skill__use_shell");
525    }
526
527    #[test]
528    fn skill_builtin_tool_description() {
529        let target: Arc<dyn Tool> = Arc::new(MockBuiltinTool::new("shell"));
530        let tool = SkillBuiltinTool::new(
531            "my_skill",
532            &sample_builtin_skill_tool(),
533            target,
534            HashMap::new(),
535        );
536        assert_eq!(tool.description(), "Elevated shell access via skill");
537    }
538
539    #[test]
540    fn skill_builtin_tool_inherits_target_schema() {
541        let target: Arc<dyn Tool> = Arc::new(MockBuiltinTool::new("shell"));
542        let tool = SkillBuiltinTool::new(
543            "my_skill",
544            &sample_builtin_skill_tool(),
545            target,
546            HashMap::new(),
547        );
548        let schema = tool.parameters_schema();
549        // Schema should come from the mock target, not the skill tool definition
550        assert_eq!(schema["type"], "object");
551        assert!(schema["properties"]["input"].is_object());
552    }
553
554    #[tokio::test]
555    async fn skill_builtin_tool_delegates_to_target() {
556        let target: Arc<dyn Tool> = Arc::new(MockBuiltinTool::new("shell"));
557        let tool = SkillBuiltinTool::new(
558            "my_skill",
559            &sample_builtin_skill_tool(),
560            target,
561            HashMap::new(),
562        );
563        let result = tool
564            .execute(serde_json::json!({"input": "hello"}))
565            .await
566            .unwrap();
567        assert!(result.success);
568        assert_eq!(result.output, "mock_result:hello");
569    }
570
571    #[test]
572    fn skill_builtin_tool_spec_roundtrip() {
573        let target: Arc<dyn Tool> = Arc::new(MockBuiltinTool::new("shell"));
574        let tool = SkillBuiltinTool::new(
575            "my_skill",
576            &sample_builtin_skill_tool(),
577            target,
578            HashMap::new(),
579        );
580        let spec = tool.spec();
581        assert_eq!(spec.name, "my_skill__use_shell");
582        assert_eq!(spec.description, "Elevated shell access via skill");
583    }
584
585    #[test]
586    fn skill_tool_serde_new_fields_default() {
587        // Verify that TOML without the new fields still parses correctly
588        let toml_str = r#"
589            name = "test"
590            description = "A test tool"
591            kind = "shell"
592            command = "echo hello"
593        "#;
594        let st: SkillTool = toml::from_str(toml_str).unwrap();
595        assert_eq!(st.name, "test");
596        assert_eq!(st.kind, "shell");
597        assert!(st.target.is_none());
598    }
599
600    #[test]
601    fn skill_tool_serde_with_builtin_fields() {
602        let toml_str = r#"
603            name = "use_shell"
604            description = "Shell via skill"
605            kind = "builtin"
606            target = "shell"
607        "#;
608        let st: SkillTool = toml::from_str(toml_str).unwrap();
609        assert_eq!(st.kind, "builtin");
610        assert_eq!(st.target.as_deref(), Some("shell"));
611    }
612
613    #[test]
614    fn skill_tool_serde_legacy_default_args_aliases_to_locked_args() {
615        // The legacy `[default_args]` key still parses into `locked_args`.
616        let toml_str = r#"
617            name = "generate_pdf"
618            description = "Generate PDF via Composio"
619            kind = "builtin"
620            target = "composio"
621
622            [default_args]
623            action_name = "TEXT_TO_PDF"
624            app = "pdfco"
625        "#;
626        let st: SkillTool = toml::from_str(toml_str).unwrap();
627        assert_eq!(st.target.as_deref(), Some("composio"));
628        assert_eq!(st.locked_args.get("action_name").unwrap(), "TEXT_TO_PDF");
629        assert_eq!(st.locked_args.get("app").unwrap(), "pdfco");
630    }
631
632    #[test]
633    fn skill_tool_serde_mcp_kind_with_locked_args() {
634        // `kind = "mcp"` targets a prefixed MCP tool name `{server}__{tool}`.
635        let toml_str = r#"
636            name = "generate_image"
637            description = "Generate an image via MCP"
638            kind = "mcp"
639            target = "images__generate"
640
641            [locked_args]
642            model = "default"
643        "#;
644        let st: SkillTool = toml::from_str(toml_str).unwrap();
645        assert_eq!(st.kind, "mcp");
646        assert_eq!(st.target.as_deref(), Some("images__generate"));
647        assert_eq!(st.locked_args.get("model").unwrap(), "default");
648    }
649
650    #[tokio::test]
651    async fn skill_builtin_tool_merges_locked_args() {
652        let target: Arc<dyn Tool> = Arc::new(MockBuiltinTool::new("composio"));
653        let mut locked = HashMap::new();
654        locked.insert("action_name".to_string(), "TEXT_TO_PDF".to_string());
655        locked.insert("app".to_string(), "pdfco".to_string());
656        let st = SkillTool {
657            name: "gen_pdf".to_string(),
658            description: "Generate PDF".to_string(),
659            kind: "builtin".to_string(),
660            command: String::new(),
661            args: HashMap::new(),
662            target: Some("composio".to_string()),
663            locked_args: locked.clone(),
664        };
665        let tool = SkillBuiltinTool::new("my_skill", &st, target, locked);
666        // Caller passes only "input"; locked args provide action_name + app.
667        let result = tool
668            .execute(serde_json::json!({"input": "hello"}))
669            .await
670            .unwrap();
671        assert!(result.success);
672        // MockBuiltinTool reads "input" — the caller's non-locked arg passes through.
673        assert_eq!(result.output, "mock_result:hello");
674    }
675
676    /// Mock target that echoes the full (merged) args it received as JSON, so a
677    /// test can assert exactly what reached the delegated target.
678    struct EchoArgsTool {
679        name: String,
680    }
681    impl ::zeroclaw_api::attribution::Attributable for EchoArgsTool {
682        fn role(&self) -> ::zeroclaw_api::attribution::Role {
683            ::zeroclaw_api::attribution::Role::Tool(::zeroclaw_api::attribution::ToolKind::Plugin)
684        }
685        fn alias(&self) -> &str {
686            &self.name
687        }
688    }
689    #[async_trait]
690    impl Tool for EchoArgsTool {
691        fn name(&self) -> &str {
692            &self.name
693        }
694        fn description(&self) -> &str {
695            "Echoes received args"
696        }
697        fn parameters_schema(&self) -> serde_json::Value {
698            serde_json::json!({
699                "type": "object",
700                "properties": {
701                    "action": { "type": "string" },
702                    "input": { "type": "string" }
703                },
704                "required": ["action"]
705            })
706        }
707        async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
708            Ok(ToolResult {
709                success: true,
710                output: args.to_string(),
711                error: None,
712            })
713        }
714    }
715
716    fn elevation_skill_tool(
717        kind: &str,
718        target: &str,
719        locked: HashMap<String, String>,
720    ) -> SkillTool {
721        SkillTool {
722            name: "delegate".to_string(),
723            description: "d".to_string(),
724            kind: kind.to_string(),
725            command: String::new(),
726            args: HashMap::new(),
727            target: Some(target.to_string()),
728            locked_args: locked,
729        }
730    }
731
732    #[tokio::test]
733    async fn skill_elevated_caller_cannot_override_locked_args() {
734        // Security regression: a caller must NOT be able to change a locked
735        // scope key (the bug was caller-wins).
736        let target: Arc<dyn Tool> = Arc::new(EchoArgsTool {
737            name: "composio".into(),
738        });
739        let mut locked = HashMap::new();
740        locked.insert("action".to_string(), "execute".to_string());
741        let st = elevation_skill_tool("builtin", "composio", locked.clone());
742        let tool = SkillBuiltinTool::new("sk", &st, target, locked);
743        let result = tool
744            .execute(serde_json::json!({"action": "DANGEROUS", "input": "x"}))
745            .await
746            .unwrap();
747        let merged: serde_json::Value = serde_json::from_str(&result.output).unwrap();
748        assert_eq!(
749            merged["action"], "execute",
750            "locked arg must not be overridable"
751        );
752        assert_eq!(
753            merged["input"], "x",
754            "caller's non-locked arg passes through"
755        );
756    }
757
758    #[test]
759    fn skill_elevated_advertised_schema_hides_locked_keys() {
760        let target: Arc<dyn Tool> = Arc::new(EchoArgsTool {
761            name: "composio".into(),
762        });
763        let mut locked = HashMap::new();
764        locked.insert("action".to_string(), "execute".to_string());
765        let st = elevation_skill_tool("builtin", "composio", locked.clone());
766        let tool = SkillBuiltinTool::new("sk", &st, target, locked);
767        let schema = tool.parameters_schema();
768        assert!(
769            schema["properties"]["action"].is_null(),
770            "locked key must be hidden from advertised schema"
771        );
772        assert!(schema["properties"]["input"].is_object());
773        let required: Vec<&str> = schema["required"]
774            .as_array()
775            .unwrap()
776            .iter()
777            .filter_map(|v| v.as_str())
778            .collect();
779        assert!(
780            !required.contains(&"action"),
781            "locked key removed from required"
782        );
783    }
784
785    #[tokio::test]
786    async fn skill_elevated_mcp_delegates_with_locked_scope() {
787        // A `kind = "mcp"` skill tool resolves to an MCP wrapper (mocked here as
788        // a tool named like `{server}__{tool}`) and locks the scope so the model
789        // cannot change the fixed MCP arguments.
790        let target: Arc<dyn Tool> = Arc::new(EchoArgsTool {
791            name: "images__generate".into(),
792        });
793        let mut locked = HashMap::new();
794        locked.insert("model".to_string(), "default".to_string());
795        let st = elevation_skill_tool("mcp", "images__generate", locked.clone());
796        let tool = SkillBuiltinTool::new("art", &st, target, locked);
797        assert_eq!(tool.name(), "art__delegate");
798        let result = tool
799            .execute(serde_json::json!({"model": "evil", "prompt": "a cat"}))
800            .await
801            .unwrap();
802        let merged: serde_json::Value = serde_json::from_str(&result.output).unwrap();
803        assert_eq!(
804            merged["model"], "default",
805            "locked MCP scope arg cannot be overridden"
806        );
807        assert_eq!(merged["prompt"], "a cat");
808    }
809
810    #[test]
811    fn merge_locked_args_locks_win_and_passthrough() {
812        let mut locked = serde_json::Map::new();
813        locked.insert("action".into(), serde_json::Value::String("execute".into()));
814        let out = super::merge_locked_args(&locked, serde_json::json!({"action": "x", "extra": 1}));
815        assert_eq!(out["action"], "execute");
816        assert_eq!(out["extra"], 1);
817        // Empty locked set returns the caller args unchanged.
818        let caller = serde_json::json!({"a": 1});
819        assert_eq!(
820            super::merge_locked_args(&serde_json::Map::new(), caller.clone()),
821            caller
822        );
823    }
824
825    #[test]
826    fn elevation_wrapper_survives_policy_filter_that_blocks_raw_target() {
827        // The trust-boundary contract (#6915): a SecurityPolicy blocking the
828        // raw tool by name must keep it out of the model-visible registry,
829        // while the skill's scoped wrapper — registered under the prefixed
830        // name — remains the only callable path to that capability.
831        use crate::skills::{Skill, SkillTool};
832
833        let shell: Arc<dyn Tool> = Arc::new(MockBuiltinTool::new("shell"));
834        let file_read: Arc<dyn Tool> = Arc::new(MockBuiltinTool::new("file_read"));
835        // The resolution registry retains the raw tool so the wrapper can
836        // delegate to it even after the policy filter removes it below.
837        let resolution: Vec<Arc<dyn Tool>> = vec![Arc::clone(&shell), Arc::clone(&file_read)];
838
839        let mut registry: Vec<Box<dyn Tool>> = vec![
840            Box::new(crate::tools::ArcToolRef(Arc::clone(&shell))),
841            Box::new(crate::tools::ArcToolRef(Arc::clone(&file_read))),
842        ];
843        let policy = SecurityPolicy {
844            excluded_tools: Some(vec!["shell".to_string()]),
845            workspace_dir: std::env::temp_dir(),
846            ..SecurityPolicy::default()
847        };
848        crate::agent::loop_::apply_policy_tool_filter(&mut registry, Some(&policy), None);
849        assert!(
850            !registry.iter().any(|t| t.name() == "shell"),
851            "raw shell must be blocked by the policy filter"
852        );
853
854        let skill = Skill {
855            name: "ops".to_string(),
856            description: "d".to_string(),
857            version: "1".to_string(),
858            author: None,
859            tags: vec![],
860            tools: vec![SkillTool {
861                name: "use_shell".to_string(),
862                description: "scoped shell".to_string(),
863                kind: "builtin".to_string(),
864                command: String::new(),
865                args: HashMap::new(),
866                target: Some("shell".to_string()),
867                locked_args: HashMap::new(),
868            }],
869            prompts: vec![],
870            location: None,
871        };
872        crate::tools::register_skill_tools_with_context(
873            &mut registry,
874            &[skill],
875            test_security(),
876            &resolution,
877        );
878
879        assert!(
880            !registry.iter().any(|t| t.name() == "shell"),
881            "raw shell must STILL be unavailable after skill registration"
882        );
883        assert!(
884            registry.iter().any(|t| t.name() == "ops__use_shell"),
885            "the scoped elevation wrapper must be the only callable path"
886        );
887    }
888}