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