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 if let Ok(names) = local::list_installed(mur_home) {
161 for name in names {
162 if seen_names.contains(&name) {
163 continue;
164 }
165 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 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 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(), })
244 } else {
245 Some(LoadedSkill {
247 name: name.into(),
248 manifest,
249 trust: TrustLevel::Sandboxed,
250 scope,
251 content_hash: hash,
252 dir: std::path::PathBuf::new(), })
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 write_to_dir(&home.join("skills").join("real"), &make("real")).unwrap();
303 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 assert!(is_valid_skill_name("web-search"));
325 assert!(is_valid_skill_name("my.skill_v2"));
326 assert!(!is_valid_skill_name("."));
328 assert!(!is_valid_skill_name(".."));
329 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 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 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 #[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 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 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 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}