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)]
35pub struct LoadedSkill {
36 pub name: String,
37 pub manifest: SkillManifest,
38 pub trust: TrustLevel,
39 pub scope: SkillScope,
40 pub content_hash: String,
41 pub dir: std::path::PathBuf,
43}
44
45pub fn load_all(mur_home: &Path, agent_name: &str) -> Vec<LoadedSkill> {
46 let trust = SkillTrustStore::load(mur_home).unwrap_or_default();
47 let mut out: Vec<LoadedSkill> = Vec::new();
48 let mut seen_names: std::collections::HashSet<String> = Default::default();
49
50 if let Ok(names) = local::list_installed_agent(mur_home, agent_name) {
52 for name in names {
53 if let Some(mut loaded) =
54 load_one(mur_home, &name, SkillScope::Agent, &trust, |m, n| {
55 local::load_installed_agent(m, agent_name, n)
56 })
57 {
58 loaded.dir = crate::skill::store::agent_skill_dir(mur_home, agent_name).join(&name);
59 seen_names.insert(loaded.name.clone());
60 out.push(loaded);
61 }
62 }
63 }
64 if let Ok(names) = local::list_installed(mur_home) {
65 for name in names {
66 if seen_names.contains(&name) {
67 continue;
68 }
69 if let Some(mut loaded) = load_one(
70 mur_home,
71 &name,
72 SkillScope::Global,
73 &trust,
74 local::load_installed,
75 ) {
76 loaded.dir = crate::skill::store::global_skill_dir(mur_home, &name);
77 out.push(loaded);
78 }
79 }
80 }
81 out
82}
83
84fn load_one<F>(
85 mur_home: &Path,
86 name: &str,
87 scope: SkillScope,
88 trust: &SkillTrustStore,
89 loader: F,
90) -> Option<LoadedSkill>
91where
92 F: FnOnce(&Path, &str) -> Result<SkillManifest, crate::skill::StoreError>,
93{
94 if !is_valid_skill_name(name) {
99 tracing::warn!(
100 skill = %name,
101 "skill name contains invalid characters (expected [A-Za-z0-9_.-]{{1,64}}); skipping"
102 );
103 return None;
104 }
105
106 let manifest = match loader(mur_home, name) {
107 Ok(m) => m,
108 Err(e) => {
109 tracing::warn!(skill = %name, error = %e, "skill load failed; skipping");
110 return None;
111 }
112 };
113 let hash = match content_sha256(&manifest) {
114 Ok(h) => h,
115 Err(e) => {
116 tracing::warn!(skill = %name, error = %e, "skill hash failed; skipping");
117 return None;
118 }
119 };
120 let entry = trust.entries.get(&hash);
123 if let Some(pinned) = entry {
124 if let Ok(DriftStatus::Drift { expected, actual }) = drift_status(&manifest, Some(&hash)) {
125 tracing::warn!(skill = %name, expected, actual, "skill drift detected; skipping");
126 return None;
127 }
128 if trust.is_revoked(&hash) {
129 tracing::warn!(skill = %name, "skill hash revoked; skipping");
130 return None;
131 }
132 Some(LoadedSkill {
133 name: name.into(),
134 manifest,
135 trust: pinned.level,
136 scope,
137 content_hash: hash,
138 dir: std::path::PathBuf::new(), })
140 } else {
141 Some(LoadedSkill {
143 name: name.into(),
144 manifest,
145 trust: TrustLevel::Sandboxed,
146 scope,
147 content_hash: hash,
148 dir: std::path::PathBuf::new(), })
150 }
151}
152
153#[cfg(test)]
154mod tests {
155 use super::*;
156 use crate::skill::{parse_canonical, write_to_dir};
157 use tempfile::tempdir;
158
159 #[test]
160 fn load_all_sets_agent_skill_dir() {
161 let dir = tempdir().unwrap();
162 let home = dir.path();
163 let sdir = home.join("agents").join("a1").join("skills").join("demo");
164 write_to_dir(&sdir, &make("demo")).unwrap();
165
166 let loaded = load_all(home, "a1");
167 let demo = loaded.iter().find(|s| s.name == "demo").unwrap();
168 assert_eq!(demo.dir, sdir);
169 }
170
171 fn make(name: &str) -> SkillManifest {
172 parse_canonical(&format!(
173 r#"name: {name}
174version: 1.0.0
175publisher: human:t
176description: test
177category: context
178content:
179 abstract: hi
180 context: body
181"#
182 ))
183 .unwrap()
184 }
185
186 #[test]
187 fn empty_mur_home_returns_empty() {
188 let dir = tempdir().unwrap();
189 let loaded = load_all(dir.path(), "alice");
190 assert!(loaded.is_empty());
191 }
192
193 #[test]
194 fn is_valid_skill_name_rejects_traversal_and_reserved() {
195 assert!(is_valid_skill_name("web-search"));
197 assert!(is_valid_skill_name("my.skill_v2"));
198 assert!(!is_valid_skill_name("."));
200 assert!(!is_valid_skill_name(".."));
201 assert!(!is_valid_skill_name("../agents/victim/skills/evil"));
203 assert!(!is_valid_skill_name("a/b"));
204 assert!(!is_valid_skill_name("a\\b"));
205 assert!(!is_valid_skill_name("/etc/passwd"));
206 assert!(!is_valid_skill_name(""));
208 assert!(!is_valid_skill_name(&"x".repeat(65)));
209 }
210
211 #[test]
212 fn global_skill_returns_sandboxed_when_no_trust_entry() {
213 let dir = tempdir().unwrap();
214 write_to_dir(&dir.path().join("skills").join("demo"), &make("demo")).unwrap();
215 let loaded = load_all(dir.path(), "alice");
216 assert_eq!(loaded.len(), 1);
217 assert_eq!(loaded[0].name, "demo");
218 assert_eq!(loaded[0].trust, TrustLevel::Sandboxed);
219 assert_eq!(loaded[0].scope, SkillScope::Global);
220 }
221
222 #[test]
223 fn agent_overrides_global_by_name() {
224 let dir = tempdir().unwrap();
225 write_to_dir(&dir.path().join("skills").join("shared"), &make("shared")).unwrap();
227 write_to_dir(
228 &dir.path()
229 .join("agents")
230 .join("alice")
231 .join("skills")
232 .join("shared"),
233 &make("shared"),
234 )
235 .unwrap();
236 let loaded = load_all(dir.path(), "alice");
237 let shared: Vec<_> = loaded.iter().filter(|s| s.name == "shared").collect();
238 assert_eq!(shared.len(), 1);
239 assert_eq!(shared[0].scope, SkillScope::Agent);
240 }
241}