1use crate::skill::types::TrustLevel;
5use crate::skill::{DriftStatus, SkillManifest, content_sha256, drift_status, 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}
52
53pub 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 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 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 if let Ok(names) = local::list_installed_agent(mur_home, agent_name) {
137 for name in names {
138 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 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(); 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 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 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 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(), })
279 } else {
280 Some(LoadedSkill {
282 name: name.into(),
283 manifest,
284 trust: TrustLevel::Sandboxed,
285 scope,
286 content_hash: hash,
287 dir: std::path::PathBuf::new(), })
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 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 write_to_dir(&home.join("skills").join("real"), &make("real")).unwrap();
387 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 assert!(is_valid_skill_name("web-search"));
409 assert!(is_valid_skill_name("my.skill_v2"));
410 assert!(!is_valid_skill_name("."));
412 assert!(!is_valid_skill_name(".."));
413 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 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 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 #[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 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 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 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}