mur_common/skill/
loader.rs1use crate::skill::types::TrustLevel;
5use crate::skill::{DriftStatus, SkillManifest, content_sha256, drift_status, local};
6use crate::trust::skills::SkillTrustStore;
7use std::path::Path;
8
9pub fn is_valid_skill_name(name: &str) -> bool {
15 !name.is_empty()
16 && name.len() <= 64
17 && name != "."
20 && name != ".."
21 && name
24 .chars()
25 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-'))
26}
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum SkillScope {
30 Global,
31 Agent,
32}
33
34#[derive(Debug, Clone)]
35pub struct LoadedSkill {
36 pub name: String,
37 pub manifest: SkillManifest,
38 pub trust: TrustLevel,
39 pub scope: SkillScope,
40 pub content_hash: String,
41}
42
43pub fn load_all(mur_home: &Path, agent_name: &str) -> Vec<LoadedSkill> {
44 let trust = SkillTrustStore::load(mur_home).unwrap_or_default();
45 let mut out: Vec<LoadedSkill> = Vec::new();
46 let mut seen_names: std::collections::HashSet<String> = Default::default();
47
48 if let Ok(names) = local::list_installed_agent(mur_home, agent_name) {
50 for name in names {
51 if let Some(loaded) = load_one(mur_home, &name, SkillScope::Agent, &trust, |m, n| {
52 local::load_installed_agent(m, agent_name, n)
53 }) {
54 seen_names.insert(loaded.name.clone());
55 out.push(loaded);
56 }
57 }
58 }
59 if let Ok(names) = local::list_installed(mur_home) {
60 for name in names {
61 if seen_names.contains(&name) {
62 continue;
63 }
64 if let Some(loaded) = load_one(
65 mur_home,
66 &name,
67 SkillScope::Global,
68 &trust,
69 local::load_installed,
70 ) {
71 out.push(loaded);
72 }
73 }
74 }
75 out
76}
77
78fn load_one<F>(
79 mur_home: &Path,
80 name: &str,
81 scope: SkillScope,
82 trust: &SkillTrustStore,
83 loader: F,
84) -> Option<LoadedSkill>
85where
86 F: FnOnce(&Path, &str) -> Result<SkillManifest, crate::skill::StoreError>,
87{
88 if !is_valid_skill_name(name) {
93 tracing::warn!(
94 skill = %name,
95 "skill name contains invalid characters (expected [A-Za-z0-9_.-]{{1,64}}); skipping"
96 );
97 return None;
98 }
99
100 let manifest = match loader(mur_home, name) {
101 Ok(m) => m,
102 Err(e) => {
103 tracing::warn!(skill = %name, error = %e, "skill load failed; skipping");
104 return None;
105 }
106 };
107 let hash = match content_sha256(&manifest) {
108 Ok(h) => h,
109 Err(e) => {
110 tracing::warn!(skill = %name, error = %e, "skill hash failed; skipping");
111 return None;
112 }
113 };
114 let entry = trust.entries.get(&hash);
117 if let Some(pinned) = entry {
118 if let Ok(DriftStatus::Drift { expected, actual }) = drift_status(&manifest, Some(&hash)) {
119 tracing::warn!(skill = %name, expected, actual, "skill drift detected; skipping");
120 return None;
121 }
122 if trust.is_revoked(&hash) {
123 tracing::warn!(skill = %name, "skill hash revoked; skipping");
124 return None;
125 }
126 Some(LoadedSkill {
127 name: name.into(),
128 manifest,
129 trust: pinned.level,
130 scope,
131 content_hash: hash,
132 })
133 } else {
134 Some(LoadedSkill {
136 name: name.into(),
137 manifest,
138 trust: TrustLevel::Sandboxed,
139 scope,
140 content_hash: hash,
141 })
142 }
143}
144
145#[cfg(test)]
146mod tests {
147 use super::*;
148 use crate::skill::{parse_canonical, write_to_dir};
149 use tempfile::tempdir;
150
151 fn make(name: &str) -> SkillManifest {
152 parse_canonical(&format!(
153 r#"name: {name}
154version: 1.0.0
155publisher: human:t
156description: test
157category: context
158content:
159 abstract: hi
160 context: body
161"#
162 ))
163 .unwrap()
164 }
165
166 #[test]
167 fn empty_mur_home_returns_empty() {
168 let dir = tempdir().unwrap();
169 let loaded = load_all(dir.path(), "alice");
170 assert!(loaded.is_empty());
171 }
172
173 #[test]
174 fn is_valid_skill_name_rejects_traversal_and_reserved() {
175 assert!(is_valid_skill_name("web-search"));
177 assert!(is_valid_skill_name("my.skill_v2"));
178 assert!(!is_valid_skill_name("."));
180 assert!(!is_valid_skill_name(".."));
181 assert!(!is_valid_skill_name("../agents/victim/skills/evil"));
183 assert!(!is_valid_skill_name("a/b"));
184 assert!(!is_valid_skill_name("a\\b"));
185 assert!(!is_valid_skill_name("/etc/passwd"));
186 assert!(!is_valid_skill_name(""));
188 assert!(!is_valid_skill_name(&"x".repeat(65)));
189 }
190
191 #[test]
192 fn global_skill_returns_sandboxed_when_no_trust_entry() {
193 let dir = tempdir().unwrap();
194 write_to_dir(&dir.path().join("skills").join("demo"), &make("demo")).unwrap();
195 let loaded = load_all(dir.path(), "alice");
196 assert_eq!(loaded.len(), 1);
197 assert_eq!(loaded[0].name, "demo");
198 assert_eq!(loaded[0].trust, TrustLevel::Sandboxed);
199 assert_eq!(loaded[0].scope, SkillScope::Global);
200 }
201
202 #[test]
203 fn agent_overrides_global_by_name() {
204 let dir = tempdir().unwrap();
205 write_to_dir(&dir.path().join("skills").join("shared"), &make("shared")).unwrap();
207 write_to_dir(
208 &dir.path()
209 .join("agents")
210 .join("alice")
211 .join("skills")
212 .join("shared"),
213 &make("shared"),
214 )
215 .unwrap();
216 let loaded = load_all(dir.path(), "alice");
217 let shared: Vec<_> = loaded.iter().filter(|s| s.name == "shared").collect();
218 assert_eq!(shared.len(), 1);
219 assert_eq!(shared[0].scope, SkillScope::Agent);
220 }
221}