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    if let Ok(names) = local::list_installed(mur_home) {
161        for name in names {
162            if seen_names.contains(&name) {
163                continue;
164            }
165            // Skip non-skill dirs (see the agent loop above) — a manifest-less
166            // dir is a ledger/data dir, not a skill.
167            if !crate::skill::store::global_skill_dir(mur_home, &name)
168                .join("skill.yaml")
169                .is_file()
170            {
171                continue;
172            }
173            if let Some(mut loaded) = load_one(
174                mur_home,
175                &name,
176                SkillScope::Global,
177                &trust,
178                local::load_installed,
179            ) {
180                loaded.dir = crate::skill::store::global_skill_dir(mur_home, &name);
181                out.push(loaded);
182            }
183        }
184    }
185    out
186}
187
188fn load_one<F>(
189    mur_home: &Path,
190    name: &str,
191    scope: SkillScope,
192    trust: &SkillTrustStore,
193    loader: F,
194) -> Option<LoadedSkill>
195where
196    F: FnOnce(&Path, &str) -> Result<SkillManifest, crate::skill::StoreError>,
197{
198    // Validate name before loading: only safe identifier characters allowed.
199    // Skill names are interpolated into XML attributes; an unvalidated name
200    // containing `"` or `>` could break the attribute boundary even after
201    // escaping if the validator itself is bypassed.
202    if !is_valid_skill_name(name) {
203        tracing::warn!(
204            skill = %name,
205            "skill name contains invalid characters (expected [A-Za-z0-9_.-]{{1,64}}); skipping"
206        );
207        return None;
208    }
209
210    let manifest = match loader(mur_home, name) {
211        Ok(m) => m,
212        Err(e) => {
213            tracing::warn!(skill = %name, error = %e, "skill load failed; skipping");
214            return None;
215        }
216    };
217    let hash = match content_sha256(&manifest) {
218        Ok(h) => h,
219        Err(e) => {
220            tracing::warn!(skill = %name, error = %e, "skill hash failed; skipping");
221            return None;
222        }
223    };
224    // Drift check: if there's a pinned hash for this skill in the trust store
225    // and it disagrees, refuse to load.
226    let entry = trust.entries.get(&hash);
227    if let Some(pinned) = entry {
228        if let Ok(DriftStatus::Drift { expected, actual }) = drift_status(&manifest, Some(&hash)) {
229            tracing::warn!(skill = %name, expected, actual, "skill drift detected; skipping");
230            return None;
231        }
232        if trust.is_revoked(&hash) {
233            tracing::warn!(skill = %name, "skill hash revoked; skipping");
234            return None;
235        }
236        Some(LoadedSkill {
237            name: name.into(),
238            manifest,
239            trust: pinned.level,
240            scope,
241            content_hash: hash,
242            dir: std::path::PathBuf::new(), // overwritten by load_all
243        })
244    } else {
245        // Unpinned = first-load Sandboxed.
246        Some(LoadedSkill {
247            name: name.into(),
248            manifest,
249            trust: TrustLevel::Sandboxed,
250            scope,
251            content_hash: hash,
252            dir: std::path::PathBuf::new(), // overwritten by load_all
253        })
254    }
255}
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260    use crate::skill::{parse_canonical, write_to_dir};
261    use tempfile::tempdir;
262
263    #[test]
264    fn load_all_sets_agent_skill_dir() {
265        let dir = tempdir().unwrap();
266        let home = dir.path();
267        let sdir = home.join("agents").join("a1").join("skills").join("demo");
268        write_to_dir(&sdir, &make("demo")).unwrap();
269
270        let loaded = load_all(home, "a1");
271        let demo = loaded.iter().find(|s| s.name == "demo").unwrap();
272        assert_eq!(demo.dir, sdir);
273    }
274
275    fn make(name: &str) -> SkillManifest {
276        parse_canonical(&format!(
277            r#"name: {name}
278version: 1.0.0
279publisher: human:t
280description: test
281category: context
282content:
283  abstract: hi
284  context: body
285"#
286        ))
287        .unwrap()
288    }
289
290    #[test]
291    fn empty_mur_home_returns_empty() {
292        let dir = tempdir().unwrap();
293        let loaded = load_all(dir.path(), "alice");
294        assert!(loaded.is_empty());
295    }
296
297    #[test]
298    fn load_all_skips_non_skill_dirs() {
299        let dir = tempdir().unwrap();
300        let home = dir.path();
301        // A real global skill (has skill.yaml)…
302        write_to_dir(&home.join("skills").join("real"), &make("real")).unwrap();
303        // …and a non-skill dir under skills/ (only events.jsonl, no skill.yaml) —
304        // e.g. a fleet run-ledger. Uses a portable name here: the real ledger id
305        // is `fleet:<name>`, but a colon is an illegal filename on Windows, so
306        // the test fixture would fail to even create it. The skip logic keys on
307        // the absent skill.yaml, not the name.
308        let ledger = home.join("skills").join("not-a-skill");
309        std::fs::create_dir_all(&ledger).unwrap();
310        std::fs::write(ledger.join("events.jsonl"), "{}\n").unwrap();
311
312        let loaded = load_all(home, "a1");
313        let names: Vec<_> = loaded.iter().map(|s| s.name.as_str()).collect();
314        assert_eq!(
315            names,
316            vec!["real"],
317            "ledger dir must not be loaded as a skill"
318        );
319    }
320
321    #[test]
322    fn is_valid_skill_name_rejects_traversal_and_reserved() {
323        // Legit names.
324        assert!(is_valid_skill_name("web-search"));
325        assert!(is_valid_skill_name("my.skill_v2"));
326        // Reserved path components.
327        assert!(!is_valid_skill_name("."));
328        assert!(!is_valid_skill_name(".."));
329        // Path separators (the dangerous traversal form) and absolutes.
330        assert!(!is_valid_skill_name("../agents/victim/skills/evil"));
331        assert!(!is_valid_skill_name("a/b"));
332        assert!(!is_valid_skill_name("a\\b"));
333        assert!(!is_valid_skill_name("/etc/passwd"));
334        // Bounds.
335        assert!(!is_valid_skill_name(""));
336        assert!(!is_valid_skill_name(&"x".repeat(65)));
337    }
338
339    #[test]
340    fn global_skill_returns_sandboxed_when_no_trust_entry() {
341        let dir = tempdir().unwrap();
342        write_to_dir(&dir.path().join("skills").join("demo"), &make("demo")).unwrap();
343        let loaded = load_all(dir.path(), "alice");
344        assert_eq!(loaded.len(), 1);
345        assert_eq!(loaded[0].name, "demo");
346        assert_eq!(loaded[0].trust, TrustLevel::Sandboxed);
347        assert_eq!(loaded[0].scope, SkillScope::Global);
348    }
349
350    #[test]
351    fn agent_overrides_global_by_name() {
352        let dir = tempdir().unwrap();
353        // Both global and agent have "shared"
354        write_to_dir(&dir.path().join("skills").join("shared"), &make("shared")).unwrap();
355        write_to_dir(
356            &dir.path()
357                .join("agents")
358                .join("alice")
359                .join("skills")
360                .join("shared"),
361            &make("shared"),
362        )
363        .unwrap();
364        let loaded = load_all(dir.path(), "alice");
365        let shared: Vec<_> = loaded.iter().filter(|s| s.name == "shared").collect();
366        assert_eq!(shared.len(), 1);
367        assert_eq!(shared[0].scope, SkillScope::Agent);
368    }
369
370    // ── skill_ref_status (#717): missing vs malformed ────────────────────
371
372    #[test]
373    fn skill_ref_status_loadable_for_installed_dir_skill() {
374        let home = tempdir().unwrap();
375        write_to_dir(&home.path().join("skills").join("demo"), &make("demo")).unwrap();
376        assert_eq!(
377            skill_ref_status(home.path(), "skills/demo"),
378            SkillRefStatus::Loadable
379        );
380    }
381
382    #[test]
383    fn skill_ref_status_absent_ref_is_missing_with_manifest_path() {
384        let home = tempdir().unwrap();
385        match skill_ref_status(home.path(), "skills/executing-plans") {
386            SkillRefStatus::Missing { path } => {
387                // The reported path names the exact manifest we expected.
388                assert!(path.ends_with("skills/executing-plans/skill.yaml"));
389            }
390            other => panic!("expected Missing, got {other:?}"),
391        }
392    }
393
394    #[test]
395    fn skill_ref_status_garbage_yaml_is_malformed() {
396        let home = tempdir().unwrap();
397        let sdir = home.path().join("skills").join("broken");
398        std::fs::create_dir_all(&sdir).unwrap();
399        std::fs::write(sdir.join("skill.yaml"), "{{{ not: [valid").unwrap();
400        assert!(matches!(
401            skill_ref_status(home.path(), "skills/broken"),
402            SkillRefStatus::Malformed { .. }
403        ));
404    }
405
406    #[test]
407    fn skill_ref_status_legacy_md_file_resolves_directly() {
408        let home = tempdir().unwrap();
409        let sdir = home.path().join("skills");
410        std::fs::create_dir_all(&sdir).unwrap();
411        // Absent legacy .md ref → Missing at the file itself (no /skill.yaml).
412        match skill_ref_status(home.path(), "skills/old.md") {
413            SkillRefStatus::Missing { path } => assert!(path.ends_with("skills/old.md")),
414            other => panic!("expected Missing, got {other:?}"),
415        }
416        // Present but unparseable legacy .md → Malformed.
417        std::fs::write(sdir.join("old.md"), "no frontmatter here").unwrap();
418        assert!(matches!(
419            skill_ref_status(home.path(), "skills/old.md"),
420            SkillRefStatus::Malformed { .. }
421        ));
422    }
423}