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    /// Absolute install directory of this skill (holds skill.yaml + any bundle).
42    pub dir: std::path::PathBuf,
43}
44
45pub fn load_all(mur_home: &Path, agent_name: &str) -> Vec<LoadedSkill> {
46    let trust = SkillTrustStore::load(mur_home).unwrap_or_default();
47    let mut out: Vec<LoadedSkill> = Vec::new();
48    let mut seen_names: std::collections::HashSet<String> = Default::default();
49
50    // Per-agent first (wins on name collision)
51    if let Ok(names) = local::list_installed_agent(mur_home, agent_name) {
52        for name in names {
53            if let Some(mut loaded) =
54                load_one(mur_home, &name, SkillScope::Agent, &trust, |m, n| {
55                    local::load_installed_agent(m, agent_name, n)
56                })
57            {
58                loaded.dir = crate::skill::store::agent_skill_dir(mur_home, agent_name).join(&name);
59                seen_names.insert(loaded.name.clone());
60                out.push(loaded);
61            }
62        }
63    }
64    if let Ok(names) = local::list_installed(mur_home) {
65        for name in names {
66            if seen_names.contains(&name) {
67                continue;
68            }
69            if let Some(mut loaded) = load_one(
70                mur_home,
71                &name,
72                SkillScope::Global,
73                &trust,
74                local::load_installed,
75            ) {
76                loaded.dir = crate::skill::store::global_skill_dir(mur_home, &name);
77                out.push(loaded);
78            }
79        }
80    }
81    out
82}
83
84fn load_one<F>(
85    mur_home: &Path,
86    name: &str,
87    scope: SkillScope,
88    trust: &SkillTrustStore,
89    loader: F,
90) -> Option<LoadedSkill>
91where
92    F: FnOnce(&Path, &str) -> Result<SkillManifest, crate::skill::StoreError>,
93{
94    // Validate name before loading: only safe identifier characters allowed.
95    // Skill names are interpolated into XML attributes; an unvalidated name
96    // containing `"` or `>` could break the attribute boundary even after
97    // escaping if the validator itself is bypassed.
98    if !is_valid_skill_name(name) {
99        tracing::warn!(
100            skill = %name,
101            "skill name contains invalid characters (expected [A-Za-z0-9_.-]{{1,64}}); skipping"
102        );
103        return None;
104    }
105
106    let manifest = match loader(mur_home, name) {
107        Ok(m) => m,
108        Err(e) => {
109            tracing::warn!(skill = %name, error = %e, "skill load failed; skipping");
110            return None;
111        }
112    };
113    let hash = match content_sha256(&manifest) {
114        Ok(h) => h,
115        Err(e) => {
116            tracing::warn!(skill = %name, error = %e, "skill hash failed; skipping");
117            return None;
118        }
119    };
120    // Drift check: if there's a pinned hash for this skill in the trust store
121    // and it disagrees, refuse to load.
122    let entry = trust.entries.get(&hash);
123    if let Some(pinned) = entry {
124        if let Ok(DriftStatus::Drift { expected, actual }) = drift_status(&manifest, Some(&hash)) {
125            tracing::warn!(skill = %name, expected, actual, "skill drift detected; skipping");
126            return None;
127        }
128        if trust.is_revoked(&hash) {
129            tracing::warn!(skill = %name, "skill hash revoked; skipping");
130            return None;
131        }
132        Some(LoadedSkill {
133            name: name.into(),
134            manifest,
135            trust: pinned.level,
136            scope,
137            content_hash: hash,
138            dir: std::path::PathBuf::new(), // overwritten by load_all
139        })
140    } else {
141        // Unpinned = first-load Sandboxed.
142        Some(LoadedSkill {
143            name: name.into(),
144            manifest,
145            trust: TrustLevel::Sandboxed,
146            scope,
147            content_hash: hash,
148            dir: std::path::PathBuf::new(), // overwritten by load_all
149        })
150    }
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156    use crate::skill::{parse_canonical, write_to_dir};
157    use tempfile::tempdir;
158
159    #[test]
160    fn load_all_sets_agent_skill_dir() {
161        let dir = tempdir().unwrap();
162        let home = dir.path();
163        let sdir = home.join("agents").join("a1").join("skills").join("demo");
164        write_to_dir(&sdir, &make("demo")).unwrap();
165
166        let loaded = load_all(home, "a1");
167        let demo = loaded.iter().find(|s| s.name == "demo").unwrap();
168        assert_eq!(demo.dir, sdir);
169    }
170
171    fn make(name: &str) -> SkillManifest {
172        parse_canonical(&format!(
173            r#"name: {name}
174version: 1.0.0
175publisher: human:t
176description: test
177category: context
178content:
179  abstract: hi
180  context: body
181"#
182        ))
183        .unwrap()
184    }
185
186    #[test]
187    fn empty_mur_home_returns_empty() {
188        let dir = tempdir().unwrap();
189        let loaded = load_all(dir.path(), "alice");
190        assert!(loaded.is_empty());
191    }
192
193    #[test]
194    fn is_valid_skill_name_rejects_traversal_and_reserved() {
195        // Legit names.
196        assert!(is_valid_skill_name("web-search"));
197        assert!(is_valid_skill_name("my.skill_v2"));
198        // Reserved path components.
199        assert!(!is_valid_skill_name("."));
200        assert!(!is_valid_skill_name(".."));
201        // Path separators (the dangerous traversal form) and absolutes.
202        assert!(!is_valid_skill_name("../agents/victim/skills/evil"));
203        assert!(!is_valid_skill_name("a/b"));
204        assert!(!is_valid_skill_name("a\\b"));
205        assert!(!is_valid_skill_name("/etc/passwd"));
206        // Bounds.
207        assert!(!is_valid_skill_name(""));
208        assert!(!is_valid_skill_name(&"x".repeat(65)));
209    }
210
211    #[test]
212    fn global_skill_returns_sandboxed_when_no_trust_entry() {
213        let dir = tempdir().unwrap();
214        write_to_dir(&dir.path().join("skills").join("demo"), &make("demo")).unwrap();
215        let loaded = load_all(dir.path(), "alice");
216        assert_eq!(loaded.len(), 1);
217        assert_eq!(loaded[0].name, "demo");
218        assert_eq!(loaded[0].trust, TrustLevel::Sandboxed);
219        assert_eq!(loaded[0].scope, SkillScope::Global);
220    }
221
222    #[test]
223    fn agent_overrides_global_by_name() {
224        let dir = tempdir().unwrap();
225        // Both global and agent have "shared"
226        write_to_dir(&dir.path().join("skills").join("shared"), &make("shared")).unwrap();
227        write_to_dir(
228            &dir.path()
229                .join("agents")
230                .join("alice")
231                .join("skills")
232                .join("shared"),
233            &make("shared"),
234        )
235        .unwrap();
236        let loaded = load_all(dir.path(), "alice");
237        let shared: Vec<_> = loaded.iter().filter(|s| s.name == "shared").collect();
238        assert_eq!(shared.len(), 1);
239        assert_eq!(shared[0].scope, SkillScope::Agent);
240    }
241}