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/// Outcome of resolving a `profile.yaml` skill ref (e.g. `skills/<name>`)
35/// against an agent's home directory.
36///
37/// Distinguishing `Missing` from `Malformed` matters: a ref written without
38/// installing the backing files (issue #717) is a *missing* skill — telling
39/// the user it "no longer parses" points them at the wrong root cause.
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub enum SkillRefStatus {
42    /// The ref resolves to a manifest that parses and validates.
43    Loadable,
44    /// No file exists at the resolved manifest path.
45    Missing { path: std::path::PathBuf },
46    /// A file exists but does not parse/validate as a skill manifest.
47    Malformed {
48        path: std::path::PathBuf,
49        error: String,
50    },
51}
52
53/// Resolve a `profile.yaml` skill ref to its backing manifest file and report
54/// whether it is loadable, missing, or malformed.
55///
56/// Resolution mirrors the runtime loader's layout rules: modern refs point at
57/// a *directory* (`skills/<name>`) holding `skill.yaml`; legacy refs may point
58/// directly at a `.yaml`/`.yml`/`.md` file. This is the single source of truth
59/// for ref resolution — the Hub loadability badge and the creation-time
60/// validation in mur-core both call it.
61pub fn skill_ref_status(agent_home: &Path, rel_ref: &str) -> SkillRefStatus {
62    let joined = agent_home.join(rel_ref);
63    let ext = joined
64        .extension()
65        .and_then(|e| e.to_str())
66        .unwrap_or("")
67        .to_ascii_lowercase();
68    let file = if joined.is_dir() || !matches!(ext.as_str(), "yaml" | "yml" | "md" | "markdown") {
69        // Modern directory layout: the ref names the skill dir; the manifest
70        // lives inside it. Also used when the dir is absent, so the Missing
71        // path names the exact manifest we expected to find.
72        joined.join("skill.yaml")
73    } else {
74        joined
75    };
76    if !file.is_file() {
77        return SkillRefStatus::Missing { path: file };
78    }
79    let text = match std::fs::read_to_string(&file) {
80        Ok(t) => t,
81        Err(e) => {
82            return SkillRefStatus::Malformed {
83                path: file,
84                error: format!("unreadable: {e}"),
85            };
86        }
87    };
88    let ext = file
89        .extension()
90        .and_then(|e| e.to_str())
91        .unwrap_or("")
92        .to_ascii_lowercase();
93    let parsed = match ext.as_str() {
94        "yaml" | "yml" => crate::skill::parse_canonical(&text),
95        "md" | "markdown" => crate::skill::parse_markdown(&text)
96            .or_else(|_| crate::skill::parse_legacy_markdown(&text)),
97        other => {
98            return SkillRefStatus::Malformed {
99                path: file,
100                error: format!("unsupported manifest extension '.{other}'"),
101            };
102        }
103    };
104    match parsed {
105        Ok(m) => match crate::skill::validate(&m) {
106            Ok(()) => SkillRefStatus::Loadable,
107            Err(e) => SkillRefStatus::Malformed {
108                path: file,
109                error: format!("invalid manifest: {e}"),
110            },
111        },
112        Err(e) => SkillRefStatus::Malformed {
113            path: file,
114            error: format!("parse failed: {e}"),
115        },
116    }
117}
118
119#[derive(Debug, Clone)]
120pub struct LoadedSkill {
121    pub name: String,
122    pub manifest: SkillManifest,
123    pub trust: TrustLevel,
124    pub scope: SkillScope,
125    pub content_hash: String,
126    /// Absolute install directory of this skill (holds skill.yaml + any bundle).
127    pub dir: std::path::PathBuf,
128}
129
130pub fn load_all(mur_home: &Path, agent_name: &str) -> Vec<LoadedSkill> {
131    let trust = SkillTrustStore::load(mur_home).unwrap_or_default();
132    let mut out: Vec<LoadedSkill> = Vec::new();
133    let mut seen_names: std::collections::HashSet<String> = Default::default();
134
135    // Per-agent first (wins on name collision)
136    if let Ok(names) = local::list_installed_agent(mur_home, agent_name) {
137        for name in names {
138            // Skip non-skill dirs (e.g. a fleet run-ledger `fleet:<name>/`
139            // written under skills/ by the DAG executor's record_run — it holds
140            // events.jsonl, not skill.yaml). Without this, its colon name trips
141            // is_valid_skill_name in load_one and spams a warning every load.
142            if !crate::skill::store::agent_skill_dir(mur_home, agent_name)
143                .join(&name)
144                .join("skill.yaml")
145                .is_file()
146            {
147                continue;
148            }
149            if let Some(mut loaded) =
150                load_one(mur_home, &name, SkillScope::Agent, &trust, |m, n| {
151                    local::load_installed_agent(m, agent_name, n)
152                })
153            {
154                loaded.dir = crate::skill::store::agent_skill_dir(mur_home, agent_name).join(&name);
155                seen_names.insert(loaded.name.clone());
156                out.push(loaded);
157            }
158        }
159    }
160    // Federated knowledge cache next (daemon-assembled snapshot of global
161    // skills at or above the lifecycle floor; federation P0). A cache entry
162    // wins over a same-named global — it IS that global skill, scope-filtered
163    // — but never over a per-agent install.
164    let cache_dir = mur_home
165        .join("agents")
166        .join(agent_name)
167        .join("knowledge_cache");
168    if let Ok(entries) = std::fs::read_dir(&cache_dir) {
169        let mut names: Vec<String> = entries
170            .filter_map(|e| e.ok())
171            .filter(|e| e.path().join("skill.yaml").is_file())
172            .filter_map(|e| e.file_name().to_str().map(String::from))
173            .collect();
174        names.sort(); // deterministic load order
175        for name in names {
176            if seen_names.contains(&name) {
177                continue;
178            }
179            let dir = cache_dir.join(&name);
180            let dir_for_loader = dir.clone();
181            if let Some(mut loaded) = load_one(
182                mur_home,
183                &name,
184                SkillScope::Global,
185                &trust,
186                move |_m, _n| crate::skill::read_from_dir(&dir_for_loader),
187            ) {
188                loaded.dir = dir;
189                seen_names.insert(loaded.name.clone());
190                out.push(loaded);
191            }
192        }
193    }
194
195    if let Ok(names) = local::list_installed(mur_home) {
196        for name in names {
197            if seen_names.contains(&name) {
198                continue;
199            }
200            // Skip non-skill dirs (see the agent loop above) — a manifest-less
201            // dir is a ledger/data dir, not a skill.
202            if !crate::skill::store::global_skill_dir(mur_home, &name)
203                .join("skill.yaml")
204                .is_file()
205            {
206                continue;
207            }
208            if let Some(mut loaded) = load_one(
209                mur_home,
210                &name,
211                SkillScope::Global,
212                &trust,
213                local::load_installed,
214            ) {
215                loaded.dir = crate::skill::store::global_skill_dir(mur_home, &name);
216                out.push(loaded);
217            }
218        }
219    }
220    out
221}
222
223fn load_one<F>(
224    mur_home: &Path,
225    name: &str,
226    scope: SkillScope,
227    trust: &SkillTrustStore,
228    loader: F,
229) -> Option<LoadedSkill>
230where
231    F: FnOnce(&Path, &str) -> Result<SkillManifest, crate::skill::StoreError>,
232{
233    // Validate name before loading: only safe identifier characters allowed.
234    // Skill names are interpolated into XML attributes; an unvalidated name
235    // containing `"` or `>` could break the attribute boundary even after
236    // escaping if the validator itself is bypassed.
237    if !is_valid_skill_name(name) {
238        tracing::warn!(
239            skill = %name,
240            "skill name contains invalid characters (expected [A-Za-z0-9_.-]{{1,64}}); skipping"
241        );
242        return None;
243    }
244
245    let manifest = match loader(mur_home, name) {
246        Ok(m) => m,
247        Err(e) => {
248            tracing::warn!(skill = %name, error = %e, "skill load failed; skipping");
249            return None;
250        }
251    };
252    let hash = match content_sha256(&manifest) {
253        Ok(h) => h,
254        Err(e) => {
255            tracing::warn!(skill = %name, error = %e, "skill hash failed; skipping");
256            return None;
257        }
258    };
259    // Drift check: if there's a pinned hash for this skill in the trust store
260    // and it disagrees, refuse to load.
261    let entry = trust.entries.get(&hash);
262    if let Some(pinned) = entry {
263        if let Ok(DriftStatus::Drift { expected, actual }) = drift_status(&manifest, Some(&hash)) {
264            tracing::warn!(skill = %name, expected, actual, "skill drift detected; skipping");
265            return None;
266        }
267        if trust.is_revoked(&hash) {
268            tracing::warn!(skill = %name, "skill hash revoked; skipping");
269            return None;
270        }
271        Some(LoadedSkill {
272            name: name.into(),
273            manifest,
274            trust: pinned.level,
275            scope,
276            content_hash: hash,
277            dir: std::path::PathBuf::new(), // overwritten by load_all
278        })
279    } else {
280        // Unpinned = first-load Sandboxed.
281        Some(LoadedSkill {
282            name: name.into(),
283            manifest,
284            trust: TrustLevel::Sandboxed,
285            scope,
286            content_hash: hash,
287            dir: std::path::PathBuf::new(), // overwritten by load_all
288        })
289    }
290}
291
292#[cfg(test)]
293mod tests {
294    use super::*;
295    use crate::skill::{parse_canonical, write_to_dir};
296    use tempfile::tempdir;
297
298    #[test]
299    fn load_all_sets_agent_skill_dir() {
300        let dir = tempdir().unwrap();
301        let home = dir.path();
302        let sdir = home.join("agents").join("a1").join("skills").join("demo");
303        write_to_dir(&sdir, &make("demo")).unwrap();
304
305        let loaded = load_all(home, "a1");
306        let demo = loaded.iter().find(|s| s.name == "demo").unwrap();
307        assert_eq!(demo.dir, sdir);
308    }
309
310    fn make(name: &str) -> SkillManifest {
311        make_desc(name, "test")
312    }
313
314    fn make_desc(name: &str, desc: &str) -> SkillManifest {
315        parse_canonical(&format!(
316            r#"name: {name}
317version: 1.0.0
318publisher: human:t
319description: {desc}
320category: context
321content:
322  abstract: hi
323  context: body
324"#
325        ))
326        .unwrap()
327    }
328
329    #[test]
330    fn knowledge_cache_skill_loads() {
331        let dir = tempdir().unwrap();
332        let home = dir.path();
333        let cdir = home
334            .join("agents/a1/knowledge_cache")
335            .join("federated-skill");
336        write_to_dir(&cdir, &make("federated-skill")).unwrap();
337
338        let loaded = load_all(home, "a1");
339        let hit = loaded
340            .iter()
341            .find(|s| s.name == "federated-skill")
342            .expect("cached skill must be visible to the loader");
343        assert_eq!(hit.dir, cdir);
344    }
345
346    #[test]
347    fn agent_local_wins_over_cache_wins_over_global() {
348        let dir = tempdir().unwrap();
349        let home = dir.path();
350        write_to_dir(
351            &home.join("agents/a1/skills/dup"),
352            &make_desc("dup", "agent-local"),
353        )
354        .unwrap();
355        write_to_dir(
356            &home.join("agents/a1/knowledge_cache/dup"),
357            &make_desc("dup", "cache"),
358        )
359        .unwrap();
360        write_to_dir(&home.join("skills/dup"), &make_desc("dup", "global")).unwrap();
361
362        let loaded = load_all(home, "a1");
363        let dups: Vec<_> = loaded.iter().filter(|s| s.name == "dup").collect();
364        assert_eq!(dups.len(), 1, "name collision must resolve to ONE copy");
365        assert_eq!(dups[0].manifest.description, "agent-local");
366
367        // Remove the per-agent copy: the cache copy takes over, not the global.
368        std::fs::remove_dir_all(home.join("agents/a1/skills/dup")).unwrap();
369        let loaded = load_all(home, "a1");
370        let dup = loaded.iter().find(|s| s.name == "dup").unwrap();
371        assert_eq!(dup.manifest.description, "cache");
372    }
373
374    #[test]
375    fn empty_mur_home_returns_empty() {
376        let dir = tempdir().unwrap();
377        let loaded = load_all(dir.path(), "alice");
378        assert!(loaded.is_empty());
379    }
380
381    #[test]
382    fn load_all_skips_non_skill_dirs() {
383        let dir = tempdir().unwrap();
384        let home = dir.path();
385        // A real global skill (has skill.yaml)…
386        write_to_dir(&home.join("skills").join("real"), &make("real")).unwrap();
387        // …and a non-skill dir under skills/ (only events.jsonl, no skill.yaml) —
388        // e.g. a fleet run-ledger. Uses a portable name here: the real ledger id
389        // is `fleet:<name>`, but a colon is an illegal filename on Windows, so
390        // the test fixture would fail to even create it. The skip logic keys on
391        // the absent skill.yaml, not the name.
392        let ledger = home.join("skills").join("not-a-skill");
393        std::fs::create_dir_all(&ledger).unwrap();
394        std::fs::write(ledger.join("events.jsonl"), "{}\n").unwrap();
395
396        let loaded = load_all(home, "a1");
397        let names: Vec<_> = loaded.iter().map(|s| s.name.as_str()).collect();
398        assert_eq!(
399            names,
400            vec!["real"],
401            "ledger dir must not be loaded as a skill"
402        );
403    }
404
405    #[test]
406    fn is_valid_skill_name_rejects_traversal_and_reserved() {
407        // Legit names.
408        assert!(is_valid_skill_name("web-search"));
409        assert!(is_valid_skill_name("my.skill_v2"));
410        // Reserved path components.
411        assert!(!is_valid_skill_name("."));
412        assert!(!is_valid_skill_name(".."));
413        // Path separators (the dangerous traversal form) and absolutes.
414        assert!(!is_valid_skill_name("../agents/victim/skills/evil"));
415        assert!(!is_valid_skill_name("a/b"));
416        assert!(!is_valid_skill_name("a\\b"));
417        assert!(!is_valid_skill_name("/etc/passwd"));
418        // Bounds.
419        assert!(!is_valid_skill_name(""));
420        assert!(!is_valid_skill_name(&"x".repeat(65)));
421    }
422
423    #[test]
424    fn global_skill_returns_sandboxed_when_no_trust_entry() {
425        let dir = tempdir().unwrap();
426        write_to_dir(&dir.path().join("skills").join("demo"), &make("demo")).unwrap();
427        let loaded = load_all(dir.path(), "alice");
428        assert_eq!(loaded.len(), 1);
429        assert_eq!(loaded[0].name, "demo");
430        assert_eq!(loaded[0].trust, TrustLevel::Sandboxed);
431        assert_eq!(loaded[0].scope, SkillScope::Global);
432    }
433
434    #[test]
435    fn agent_overrides_global_by_name() {
436        let dir = tempdir().unwrap();
437        // Both global and agent have "shared"
438        write_to_dir(&dir.path().join("skills").join("shared"), &make("shared")).unwrap();
439        write_to_dir(
440            &dir.path()
441                .join("agents")
442                .join("alice")
443                .join("skills")
444                .join("shared"),
445            &make("shared"),
446        )
447        .unwrap();
448        let loaded = load_all(dir.path(), "alice");
449        let shared: Vec<_> = loaded.iter().filter(|s| s.name == "shared").collect();
450        assert_eq!(shared.len(), 1);
451        assert_eq!(shared[0].scope, SkillScope::Agent);
452    }
453
454    // ── skill_ref_status (#717): missing vs malformed ────────────────────
455
456    #[test]
457    fn skill_ref_status_loadable_for_installed_dir_skill() {
458        let home = tempdir().unwrap();
459        write_to_dir(&home.path().join("skills").join("demo"), &make("demo")).unwrap();
460        assert_eq!(
461            skill_ref_status(home.path(), "skills/demo"),
462            SkillRefStatus::Loadable
463        );
464    }
465
466    #[test]
467    fn skill_ref_status_absent_ref_is_missing_with_manifest_path() {
468        let home = tempdir().unwrap();
469        match skill_ref_status(home.path(), "skills/executing-plans") {
470            SkillRefStatus::Missing { path } => {
471                // The reported path names the exact manifest we expected.
472                assert!(path.ends_with("skills/executing-plans/skill.yaml"));
473            }
474            other => panic!("expected Missing, got {other:?}"),
475        }
476    }
477
478    #[test]
479    fn skill_ref_status_garbage_yaml_is_malformed() {
480        let home = tempdir().unwrap();
481        let sdir = home.path().join("skills").join("broken");
482        std::fs::create_dir_all(&sdir).unwrap();
483        std::fs::write(sdir.join("skill.yaml"), "{{{ not: [valid").unwrap();
484        assert!(matches!(
485            skill_ref_status(home.path(), "skills/broken"),
486            SkillRefStatus::Malformed { .. }
487        ));
488    }
489
490    #[test]
491    fn skill_ref_status_legacy_md_file_resolves_directly() {
492        let home = tempdir().unwrap();
493        let sdir = home.path().join("skills");
494        std::fs::create_dir_all(&sdir).unwrap();
495        // Absent legacy .md ref → Missing at the file itself (no /skill.yaml).
496        match skill_ref_status(home.path(), "skills/old.md") {
497            SkillRefStatus::Missing { path } => assert!(path.ends_with("skills/old.md")),
498            other => panic!("expected Missing, got {other:?}"),
499        }
500        // Present but unparseable legacy .md → Malformed.
501        std::fs::write(sdir.join("old.md"), "no frontmatter here").unwrap();
502        assert!(matches!(
503            skill_ref_status(home.path(), "skills/old.md"),
504            SkillRefStatus::Malformed { .. }
505        ));
506    }
507}