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        // Reserved path components: a skill name is joined into
18        // `<mur_home>/skills/<name>`, so `.`/`..` must never be accepted.
19        && name != "."
20        && name != ".."
21        // The character set already excludes `/` and `\`, which keeps a name to
22        // a single path component (no traversal into sibling/parent dirs).
23        && 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    // Per-agent first (wins on name collision)
49    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    // Validate name before loading: only safe identifier characters allowed.
89    // Skill names are interpolated into XML attributes; an unvalidated name
90    // containing `"` or `>` could break the attribute boundary even after
91    // escaping if the validator itself is bypassed.
92    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    // Drift check: if there's a pinned hash for this skill in the trust store
115    // and it disagrees, refuse to load.
116    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        // Unpinned = first-load Sandboxed.
135        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        // Legit names.
176        assert!(is_valid_skill_name("web-search"));
177        assert!(is_valid_skill_name("my.skill_v2"));
178        // Reserved path components.
179        assert!(!is_valid_skill_name("."));
180        assert!(!is_valid_skill_name(".."));
181        // Path separators (the dangerous traversal form) and absolutes.
182        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        // Bounds.
187        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        // Both global and agent have "shared"
206        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}