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/// Validate that a skill name contains only safe identifier characters.
10///
11/// Skill names are interpolated into XML-like `<skill-instruction source="…">`
12/// attributes.  Restricting the character set at load time means injection is
13/// blocked at the source rather than relying solely on escaping at emit time.
14pub 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    // Per-agent first (wins on name collision)
43    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    // Validate name before loading: only safe identifier characters allowed.
83    // Skill names are interpolated into XML attributes; an unvalidated name
84    // containing `"` or `>` could break the attribute boundary even after
85    // escaping if the validator itself is bypassed.
86    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    // Drift check: if there's a pinned hash for this skill in the trust store
109    // and it disagrees, refuse to load.
110    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        // Unpinned = first-load Sandboxed.
129        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        // Both global and agent have "shared"
182        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}