1use crate::skill::types::TrustLevel;
5use crate::skill::{SkillManifest, content_hash_for_trust, local};
6use crate::trust::skills::SkillTrustStore;
7use std::path::Path;
8
9pub fn is_valid_skill_name(name: &str) -> bool {
15 !name.is_empty()
16 && name.len() <= 64
17 && name != "."
20 && name != ".."
21 && 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, PartialEq, Eq)]
41pub enum SkillRefStatus {
42 Loadable,
44 Missing { path: std::path::PathBuf },
46 Malformed {
48 path: std::path::PathBuf,
49 error: String,
50 },
51 CorruptRef { reason: String },
62}
63
64pub 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 joined.join("skill.yaml")
84 } else {
85 joined
86 };
87 if !file.is_file() {
88 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 pub dir: std::path::PathBuf,
149}
150
151pub fn load_all(mur_home: &Path, agent_name: &str) -> Vec<LoadedSkill> {
152 let trust = load_trust_migrated(mur_home);
153 let mut out: Vec<LoadedSkill> = Vec::new();
154 let mut seen_names: std::collections::HashSet<String> = Default::default();
155
156 if let Ok(names) = local::list_installed_agent(mur_home, agent_name) {
158 for name in names {
159 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 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(); 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 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_trust_migrated(mur_home: &Path) -> SkillTrustStore {
253 let mut trust = SkillTrustStore::load(mur_home).unwrap_or_default();
254 let Some(rekeyed) =
255 trust.migrate_to_trust_hash(|name| local::load_installed(mur_home, name).ok())
256 else {
257 return trust; };
259 {
260 match trust.save(mur_home) {
261 Ok(()) => tracing::info!(
262 rekeyed,
263 "skill trust store migrated to the trust-hash domain"
264 ),
265 Err(e) => tracing::warn!(error = %e, "could not persist trust-store migration"),
268 }
269 }
270 trust
271}
272
273fn load_one<F>(
274 mur_home: &Path,
275 name: &str,
276 scope: SkillScope,
277 trust: &SkillTrustStore,
278 loader: F,
279) -> Option<LoadedSkill>
280where
281 F: FnOnce(&Path, &str) -> Result<SkillManifest, crate::skill::StoreError>,
282{
283 if !is_valid_skill_name(name) {
288 tracing::warn!(
289 skill = %name,
290 "skill name contains invalid characters (expected [A-Za-z0-9_.-]{{1,64}}); skipping"
291 );
292 return None;
293 }
294
295 let manifest = match loader(mur_home, name) {
296 Ok(m) => m,
297 Err(e) => {
298 tracing::warn!(skill = %name, error = %e, "skill load failed; skipping");
299 return None;
300 }
301 };
302 let hash = match content_hash_for_trust(&manifest) {
309 Ok(h) => h,
310 Err(e) => {
311 tracing::warn!(skill = %name, error = %e, "skill hash failed; skipping");
312 return None;
313 }
314 };
315 let entry = trust.entries.get(&hash);
322 if let Some(pinned) = entry {
323 if trust.is_revoked(&hash) {
324 tracing::warn!(skill = %name, "skill hash revoked; skipping");
325 return None;
326 }
327 Some(LoadedSkill {
328 name: name.into(),
329 manifest,
330 trust: pinned.level,
331 scope,
332 content_hash: hash,
333 dir: std::path::PathBuf::new(), })
335 } else {
336 Some(LoadedSkill {
338 name: name.into(),
339 manifest,
340 trust: TrustLevel::Sandboxed,
341 scope,
342 content_hash: hash,
343 dir: std::path::PathBuf::new(), })
345 }
346}
347
348#[cfg(test)]
349mod tests {
350 use super::*;
351 use crate::skill::{parse_canonical, write_to_dir};
352 use tempfile::tempdir;
353
354 #[test]
361 fn a_generation_increment_does_not_lose_the_recorded_trust_level() {
362 use crate::trust::skills::{SkillTrustStore, TrustEntry};
363 let dir = tempdir().unwrap();
364 let home = dir.path();
365 let mut m = make("evolving");
366 let sdir = home
367 .join("agents")
368 .join("a1")
369 .join("skills")
370 .join("evolving");
371 write_to_dir(&sdir, &m).unwrap();
372
373 let key = crate::skill::content_hash_for_trust(&m).unwrap();
375 let mut trust = SkillTrustStore::default();
376 trust.insert(
377 key.clone(),
378 TrustEntry {
379 name: "evolving".into(),
380 version: m.version.clone(),
381 level: TrustLevel::Trusted,
382 installed_at: "2026-08-19T00:00:00Z".into(),
383 ..Default::default()
384 },
385 );
386 trust.save(home).unwrap();
387
388 let before_plain = crate::skill::content_sha256(&m).unwrap();
390 m.evolution_log
391 .push(crate::skill::evolution::EvolutionEvent::initial_human(
392 "t", "1.0.0",
393 ));
394 write_to_dir(&sdir, &m).unwrap();
395 let after_plain = crate::skill::content_sha256(&m).unwrap();
396 assert_ne!(
397 before_plain, after_plain,
398 "precondition: an evolution entry must move the plain content hash"
399 );
400 assert_eq!(
401 key,
402 crate::skill::content_hash_for_trust(&m).unwrap(),
403 "precondition: the trust hash must be stable across a generation increment"
404 );
405
406 let loaded = load_all(home, "a1");
407 let s = loaded.iter().find(|s| s.name == "evolving").unwrap();
408 assert_eq!(
409 s.trust,
410 TrustLevel::Trusted,
411 "the recorded trust level was lost when the skill evolved"
412 );
413 }
414
415 #[test]
419 fn a_legacy_store_is_migrated_on_load() {
420 use crate::trust::skills::SkillTrustStore;
421 let dir = tempdir().unwrap();
422 let home = dir.path();
423 let mut m = make("legacy");
427 m.evolution_log
428 .push(crate::skill::evolution::EvolutionEvent::initial_human(
429 "t", "1.0.0",
430 ));
431 write_to_dir(&home.join("skills").join("legacy"), &m).unwrap();
432
433 let legacy_key = crate::skill::content_sha256(&m).unwrap();
435 let trust_key = crate::skill::content_hash_for_trust(&m).unwrap();
436 assert_ne!(
437 legacy_key, trust_key,
438 "precondition: the two domains must differ for this to be a migration"
439 );
440 let json = format!(
441 r#"{{"entries":{{"{legacy_key}":{{"name":"legacy","version":"{}","level":"trusted","installed_at":"2026-08-19T00:00:00Z"}}}},"revoked":[]}}"#,
442 m.version
443 );
444 std::fs::create_dir_all(home.join("trust")).unwrap();
445 std::fs::write(SkillTrustStore::path(home), json).unwrap();
446
447 let loaded = load_all(home, "a1");
448 let s = loaded.iter().find(|s| s.name == "legacy").unwrap();
449 assert_eq!(
450 s.trust,
451 TrustLevel::Trusted,
452 "a v1 entry must survive the domain change"
453 );
454
455 let reloaded = SkillTrustStore::load(home).unwrap();
457 assert_eq!(reloaded.schema, crate::trust::skills::TRUST_STORE_SCHEMA);
458 assert!(reloaded.entries.contains_key(&trust_key));
459 assert!(!reloaded.entries.contains_key(&legacy_key));
460 }
461
462 #[test]
463 fn load_all_sets_agent_skill_dir() {
464 let dir = tempdir().unwrap();
465 let home = dir.path();
466 let sdir = home.join("agents").join("a1").join("skills").join("demo");
467 write_to_dir(&sdir, &make("demo")).unwrap();
468
469 let loaded = load_all(home, "a1");
470 let demo = loaded.iter().find(|s| s.name == "demo").unwrap();
471 assert_eq!(demo.dir, sdir);
472 }
473
474 fn make(name: &str) -> SkillManifest {
475 make_desc(name, "test")
476 }
477
478 fn make_desc(name: &str, desc: &str) -> SkillManifest {
479 parse_canonical(&format!(
480 r#"name: {name}
481version: 1.0.0
482publisher: human:t
483description: {desc}
484category: context
485content:
486 abstract: hi
487 context: body
488"#
489 ))
490 .unwrap()
491 }
492
493 #[test]
494 fn knowledge_cache_skill_loads() {
495 let dir = tempdir().unwrap();
496 let home = dir.path();
497 let cdir = home
498 .join("agents/a1/knowledge_cache")
499 .join("federated-skill");
500 write_to_dir(&cdir, &make("federated-skill")).unwrap();
501
502 let loaded = load_all(home, "a1");
503 let hit = loaded
504 .iter()
505 .find(|s| s.name == "federated-skill")
506 .expect("cached skill must be visible to the loader");
507 assert_eq!(hit.dir, cdir);
508 }
509
510 #[test]
511 fn agent_local_wins_over_cache_wins_over_global() {
512 let dir = tempdir().unwrap();
513 let home = dir.path();
514 write_to_dir(
515 &home.join("agents/a1/skills/dup"),
516 &make_desc("dup", "agent-local"),
517 )
518 .unwrap();
519 write_to_dir(
520 &home.join("agents/a1/knowledge_cache/dup"),
521 &make_desc("dup", "cache"),
522 )
523 .unwrap();
524 write_to_dir(&home.join("skills/dup"), &make_desc("dup", "global")).unwrap();
525
526 let loaded = load_all(home, "a1");
527 let dups: Vec<_> = loaded.iter().filter(|s| s.name == "dup").collect();
528 assert_eq!(dups.len(), 1, "name collision must resolve to ONE copy");
529 assert_eq!(dups[0].manifest.description, "agent-local");
530
531 std::fs::remove_dir_all(home.join("agents/a1/skills/dup")).unwrap();
533 let loaded = load_all(home, "a1");
534 let dup = loaded.iter().find(|s| s.name == "dup").unwrap();
535 assert_eq!(dup.manifest.description, "cache");
536 }
537
538 #[test]
539 fn empty_mur_home_returns_empty() {
540 let dir = tempdir().unwrap();
541 let loaded = load_all(dir.path(), "alice");
542 assert!(loaded.is_empty());
543 }
544
545 #[test]
546 fn load_all_skips_non_skill_dirs() {
547 let dir = tempdir().unwrap();
548 let home = dir.path();
549 write_to_dir(&home.join("skills").join("real"), &make("real")).unwrap();
551 let ledger = home.join("skills").join("not-a-skill");
557 std::fs::create_dir_all(&ledger).unwrap();
558 std::fs::write(ledger.join("events.jsonl"), "{}\n").unwrap();
559
560 let loaded = load_all(home, "a1");
561 let names: Vec<_> = loaded.iter().map(|s| s.name.as_str()).collect();
562 assert_eq!(
563 names,
564 vec!["real"],
565 "ledger dir must not be loaded as a skill"
566 );
567 }
568
569 #[test]
570 fn is_valid_skill_name_rejects_traversal_and_reserved() {
571 assert!(is_valid_skill_name("web-search"));
573 assert!(is_valid_skill_name("my.skill_v2"));
574 assert!(!is_valid_skill_name("."));
576 assert!(!is_valid_skill_name(".."));
577 assert!(!is_valid_skill_name("../agents/victim/skills/evil"));
579 assert!(!is_valid_skill_name("a/b"));
580 assert!(!is_valid_skill_name("a\\b"));
581 assert!(!is_valid_skill_name("/etc/passwd"));
582 assert!(!is_valid_skill_name(""));
584 assert!(!is_valid_skill_name(&"x".repeat(65)));
585 }
586
587 #[test]
588 fn global_skill_returns_sandboxed_when_no_trust_entry() {
589 let dir = tempdir().unwrap();
590 write_to_dir(&dir.path().join("skills").join("demo"), &make("demo")).unwrap();
591 let loaded = load_all(dir.path(), "alice");
592 assert_eq!(loaded.len(), 1);
593 assert_eq!(loaded[0].name, "demo");
594 assert_eq!(loaded[0].trust, TrustLevel::Sandboxed);
595 assert_eq!(loaded[0].scope, SkillScope::Global);
596 }
597
598 #[test]
599 fn agent_overrides_global_by_name() {
600 let dir = tempdir().unwrap();
601 write_to_dir(&dir.path().join("skills").join("shared"), &make("shared")).unwrap();
603 write_to_dir(
604 &dir.path()
605 .join("agents")
606 .join("alice")
607 .join("skills")
608 .join("shared"),
609 &make("shared"),
610 )
611 .unwrap();
612 let loaded = load_all(dir.path(), "alice");
613 let shared: Vec<_> = loaded.iter().filter(|s| s.name == "shared").collect();
614 assert_eq!(shared.len(), 1);
615 assert_eq!(shared[0].scope, SkillScope::Agent);
616 }
617
618 #[test]
621 fn skill_ref_status_loadable_for_installed_dir_skill() {
622 let home = tempdir().unwrap();
623 write_to_dir(&home.path().join("skills").join("demo"), &make("demo")).unwrap();
624 assert_eq!(
625 skill_ref_status(home.path(), "skills/demo"),
626 SkillRefStatus::Loadable
627 );
628 }
629
630 #[test]
631 fn skill_ref_status_absent_ref_is_missing_with_manifest_path() {
632 let home = tempdir().unwrap();
633 match skill_ref_status(home.path(), "skills/executing-plans") {
634 SkillRefStatus::Missing { path } => {
635 assert!(path.ends_with("skills/executing-plans/skill.yaml"));
637 }
638 other => panic!("expected Missing, got {other:?}"),
639 }
640 }
641
642 #[test]
647 fn skill_ref_status_concatenated_refs_are_corrupt_not_missing() {
648 let home = tempdir().unwrap();
649 let ref_ = "skills/pm-spec-handoff - skills/brainstorming - skills/brainstorming";
650 match skill_ref_status(home.path(), ref_) {
651 SkillRefStatus::CorruptRef { reason } => {
652 assert!(reason.contains("whitespace"), "unhelpful reason: {reason}");
653 }
654 other => panic!("expected CorruptRef, got {other:?}"),
655 }
656 }
657
658 #[test]
661 fn skill_ref_status_plain_absent_ref_stays_missing() {
662 let home = tempdir().unwrap();
663 assert!(matches!(
664 skill_ref_status(home.path(), "skills/never-installed"),
665 SkillRefStatus::Missing { .. }
666 ));
667 }
668
669 #[test]
670 fn skill_ref_status_garbage_yaml_is_malformed() {
671 let home = tempdir().unwrap();
672 let sdir = home.path().join("skills").join("broken");
673 std::fs::create_dir_all(&sdir).unwrap();
674 std::fs::write(sdir.join("skill.yaml"), "{{{ not: [valid").unwrap();
675 assert!(matches!(
676 skill_ref_status(home.path(), "skills/broken"),
677 SkillRefStatus::Malformed { .. }
678 ));
679 }
680
681 #[test]
682 fn skill_ref_status_legacy_md_file_resolves_directly() {
683 let home = tempdir().unwrap();
684 let sdir = home.path().join("skills");
685 std::fs::create_dir_all(&sdir).unwrap();
686 match skill_ref_status(home.path(), "skills/old.md") {
688 SkillRefStatus::Missing { path } => assert!(path.ends_with("skills/old.md")),
689 other => panic!("expected Missing, got {other:?}"),
690 }
691 std::fs::write(sdir.join("old.md"), "no frontmatter here").unwrap();
693 assert!(matches!(
694 skill_ref_status(home.path(), "skills/old.md"),
695 SkillRefStatus::Malformed { .. }
696 ));
697 }
698}