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