1use 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
17const SKILL_SHELL_TIMEOUT_SECS: u64 = 60;
19const MAX_OUTPUT_BYTES: usize = 1_048_576;
21
22pub 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 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 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 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 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 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
197pub 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 advertised_schema: serde_json::Value,
221}
222
223impl SkillBuiltinTool {
224 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
251fn 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
270fn 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 ::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 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 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 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 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 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 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 let result = tool
668 .execute(serde_json::json!({"input": "hello"}))
669 .await
670 .unwrap();
671 assert!(result.success);
672 assert_eq!(result.output, "mock_result:hello");
674 }
675
676 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 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 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 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 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 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}