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