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            // Skip non-skill dirs (e.g. a fleet run-ledger `fleet:<name>/`
54            // written under skills/ by the DAG executor's record_run — it holds
55            // events.jsonl, not skill.yaml). Without this, its colon name trips
56            // is_valid_skill_name in load_one and spams a warning every load.
57            if !crate::skill::store::agent_skill_dir(mur_home, agent_name)
58                .join(&name)
59                .join("skill.yaml")
60                .is_file()
61            {
62                continue;
63            }
64            if let Some(mut loaded) =
65                load_one(mur_home, &name, SkillScope::Agent, &trust, |m, n| {
66                    local::load_installed_agent(m, agent_name, n)
67                })
68            {
69                loaded.dir = crate::skill::store::agent_skill_dir(mur_home, agent_name).join(&name);
70                seen_names.insert(loaded.name.clone());
71                out.push(loaded);
72            }
73        }
74    }
75    if let Ok(names) = local::list_installed(mur_home) {
76        for name in names {
77            if seen_names.contains(&name) {
78                continue;
79            }
80            // Skip non-skill dirs (see the agent loop above) — a manifest-less
81            // dir is a ledger/data dir, not a skill.
82            if !crate::skill::store::global_skill_dir(mur_home, &name)
83                .join("skill.yaml")
84                .is_file()
85            {
86                continue;
87            }
88            if let Some(mut loaded) = load_one(
89                mur_home,
90                &name,
91                SkillScope::Global,
92                &trust,
93                local::load_installed,
94            ) {
95                loaded.dir = crate::skill::store::global_skill_dir(mur_home, &name);
96                out.push(loaded);
97            }
98        }
99    }
100    out
101}
102
103fn load_one<F>(
104    mur_home: &Path,
105    name: &str,
106    scope: SkillScope,
107    trust: &SkillTrustStore,
108    loader: F,
109) -> Option<LoadedSkill>
110where
111    F: FnOnce(&Path, &str) -> Result<SkillManifest, crate::skill::StoreError>,
112{
113    // Validate name before loading: only safe identifier characters allowed.
114    // Skill names are interpolated into XML attributes; an unvalidated name
115    // containing `"` or `>` could break the attribute boundary even after
116    // escaping if the validator itself is bypassed.
117    if !is_valid_skill_name(name) {
118        tracing::warn!(
119            skill = %name,
120            "skill name contains invalid characters (expected [A-Za-z0-9_.-]{{1,64}}); skipping"
121        );
122        return None;
123    }
124
125    let manifest = match loader(mur_home, name) {
126        Ok(m) => m,
127        Err(e) => {
128            tracing::warn!(skill = %name, error = %e, "skill load failed; skipping");
129            return None;
130        }
131    };
132    let hash = match content_sha256(&manifest) {
133        Ok(h) => h,
134        Err(e) => {
135            tracing::warn!(skill = %name, error = %e, "skill hash failed; skipping");
136            return None;
137        }
138    };
139    // Drift check: if there's a pinned hash for this skill in the trust store
140    // and it disagrees, refuse to load.
141    let entry = trust.entries.get(&hash);
142    if let Some(pinned) = entry {
143        if let Ok(DriftStatus::Drift { expected, actual }) = drift_status(&manifest, Some(&hash)) {
144            tracing::warn!(skill = %name, expected, actual, "skill drift detected; skipping");
145            return None;
146        }
147        if trust.is_revoked(&hash) {
148            tracing::warn!(skill = %name, "skill hash revoked; skipping");
149            return None;
150        }
151        Some(LoadedSkill {
152            name: name.into(),
153            manifest,
154            trust: pinned.level,
155            scope,
156            content_hash: hash,
157            dir: std::path::PathBuf::new(), // overwritten by load_all
158        })
159    } else {
160        // Unpinned = first-load Sandboxed.
161        Some(LoadedSkill {
162            name: name.into(),
163            manifest,
164            trust: TrustLevel::Sandboxed,
165            scope,
166            content_hash: hash,
167            dir: std::path::PathBuf::new(), // overwritten by load_all
168        })
169    }
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175    use crate::skill::{parse_canonical, write_to_dir};
176    use tempfile::tempdir;
177
178    #[test]
179    fn load_all_sets_agent_skill_dir() {
180        let dir = tempdir().unwrap();
181        let home = dir.path();
182        let sdir = home.join("agents").join("a1").join("skills").join("demo");
183        write_to_dir(&sdir, &make("demo")).unwrap();
184
185        let loaded = load_all(home, "a1");
186        let demo = loaded.iter().find(|s| s.name == "demo").unwrap();
187        assert_eq!(demo.dir, sdir);
188    }
189
190    fn make(name: &str) -> SkillManifest {
191        parse_canonical(&format!(
192            r#"name: {name}
193version: 1.0.0
194publisher: human:t
195description: test
196category: context
197content:
198  abstract: hi
199  context: body
200"#
201        ))
202        .unwrap()
203    }
204
205    #[test]
206    fn empty_mur_home_returns_empty() {
207        let dir = tempdir().unwrap();
208        let loaded = load_all(dir.path(), "alice");
209        assert!(loaded.is_empty());
210    }
211
212    #[test]
213    fn load_all_skips_non_skill_dirs() {
214        let dir = tempdir().unwrap();
215        let home = dir.path();
216        // A real global skill (has skill.yaml)…
217        write_to_dir(&home.join("skills").join("real"), &make("real")).unwrap();
218        // …and a non-skill dir under skills/ (only events.jsonl, no skill.yaml) —
219        // e.g. a fleet run-ledger. Uses a portable name here: the real ledger id
220        // is `fleet:<name>`, but a colon is an illegal filename on Windows, so
221        // the test fixture would fail to even create it. The skip logic keys on
222        // the absent skill.yaml, not the name.
223        let ledger = home.join("skills").join("not-a-skill");
224        std::fs::create_dir_all(&ledger).unwrap();
225        std::fs::write(ledger.join("events.jsonl"), "{}\n").unwrap();
226
227        let loaded = load_all(home, "a1");
228        let names: Vec<_> = loaded.iter().map(|s| s.name.as_str()).collect();
229        assert_eq!(
230            names,
231            vec!["real"],
232            "ledger dir must not be loaded as a skill"
233        );
234    }
235
236    #[test]
237    fn is_valid_skill_name_rejects_traversal_and_reserved() {
238        // Legit names.
239        assert!(is_valid_skill_name("web-search"));
240        assert!(is_valid_skill_name("my.skill_v2"));
241        // Reserved path components.
242        assert!(!is_valid_skill_name("."));
243        assert!(!is_valid_skill_name(".."));
244        // Path separators (the dangerous traversal form) and absolutes.
245        assert!(!is_valid_skill_name("../agents/victim/skills/evil"));
246        assert!(!is_valid_skill_name("a/b"));
247        assert!(!is_valid_skill_name("a\\b"));
248        assert!(!is_valid_skill_name("/etc/passwd"));
249        // Bounds.
250        assert!(!is_valid_skill_name(""));
251        assert!(!is_valid_skill_name(&"x".repeat(65)));
252    }
253
254    #[test]
255    fn global_skill_returns_sandboxed_when_no_trust_entry() {
256        let dir = tempdir().unwrap();
257        write_to_dir(&dir.path().join("skills").join("demo"), &make("demo")).unwrap();
258        let loaded = load_all(dir.path(), "alice");
259        assert_eq!(loaded.len(), 1);
260        assert_eq!(loaded[0].name, "demo");
261        assert_eq!(loaded[0].trust, TrustLevel::Sandboxed);
262        assert_eq!(loaded[0].scope, SkillScope::Global);
263    }
264
265    #[test]
266    fn agent_overrides_global_by_name() {
267        let dir = tempdir().unwrap();
268        // Both global and agent have "shared"
269        write_to_dir(&dir.path().join("skills").join("shared"), &make("shared")).unwrap();
270        write_to_dir(
271            &dir.path()
272                .join("agents")
273                .join("alice")
274                .join("skills")
275                .join("shared"),
276            &make("shared"),
277        )
278        .unwrap();
279        let loaded = load_all(dir.path(), "alice");
280        let shared: Vec<_> = loaded.iter().filter(|s| s.name == "shared").collect();
281        assert_eq!(shared.len(), 1);
282        assert_eq!(shared[0].scope, SkillScope::Agent);
283    }
284}