Skip to main content

mur_common/skill/
loader.rs

1//! Single-pass skill loader: lists global + per-agent skills,
2//! resolves trust level, checks drift, returns one flat Vec.
3
4use 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    // Per-agent first (wins on name collision)
30    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    // Drift check: if there's a pinned hash for this skill in the trust store
84    // and it disagrees, refuse to load.
85    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        // Unpinned = first-load Sandboxed.
104        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        // Both global and agent have "shared"
157        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}