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 !crate::skill::store::agent_skill_dir(mur_home, agent_name)
58 .join(&name)
59 .join("skill.yaml")
60 .is_file()
61 {
62 continue;
63 }
64 if let Some(mut loaded) =
65 load_one(mur_home, &name, SkillScope::Agent, &trust, |m, n| {
66 local::load_installed_agent(m, agent_name, n)
67 })
68 {
69 loaded.dir = crate::skill::store::agent_skill_dir(mur_home, agent_name).join(&name);
70 seen_names.insert(loaded.name.clone());
71 out.push(loaded);
72 }
73 }
74 }
75 if let Ok(names) = local::list_installed(mur_home) {
76 for name in names {
77 if seen_names.contains(&name) {
78 continue;
79 }
80 if !crate::skill::store::global_skill_dir(mur_home, &name)
83 .join("skill.yaml")
84 .is_file()
85 {
86 continue;
87 }
88 if let Some(mut loaded) = load_one(
89 mur_home,
90 &name,
91 SkillScope::Global,
92 &trust,
93 local::load_installed,
94 ) {
95 loaded.dir = crate::skill::store::global_skill_dir(mur_home, &name);
96 out.push(loaded);
97 }
98 }
99 }
100 out
101}
102
103fn load_one<F>(
104 mur_home: &Path,
105 name: &str,
106 scope: SkillScope,
107 trust: &SkillTrustStore,
108 loader: F,
109) -> Option<LoadedSkill>
110where
111 F: FnOnce(&Path, &str) -> Result<SkillManifest, crate::skill::StoreError>,
112{
113 if !is_valid_skill_name(name) {
118 tracing::warn!(
119 skill = %name,
120 "skill name contains invalid characters (expected [A-Za-z0-9_.-]{{1,64}}); skipping"
121 );
122 return None;
123 }
124
125 let manifest = match loader(mur_home, name) {
126 Ok(m) => m,
127 Err(e) => {
128 tracing::warn!(skill = %name, error = %e, "skill load failed; skipping");
129 return None;
130 }
131 };
132 let hash = match content_sha256(&manifest) {
133 Ok(h) => h,
134 Err(e) => {
135 tracing::warn!(skill = %name, error = %e, "skill hash failed; skipping");
136 return None;
137 }
138 };
139 let entry = trust.entries.get(&hash);
142 if let Some(pinned) = entry {
143 if let Ok(DriftStatus::Drift { expected, actual }) = drift_status(&manifest, Some(&hash)) {
144 tracing::warn!(skill = %name, expected, actual, "skill drift detected; skipping");
145 return None;
146 }
147 if trust.is_revoked(&hash) {
148 tracing::warn!(skill = %name, "skill hash revoked; skipping");
149 return None;
150 }
151 Some(LoadedSkill {
152 name: name.into(),
153 manifest,
154 trust: pinned.level,
155 scope,
156 content_hash: hash,
157 dir: std::path::PathBuf::new(), })
159 } else {
160 Some(LoadedSkill {
162 name: name.into(),
163 manifest,
164 trust: TrustLevel::Sandboxed,
165 scope,
166 content_hash: hash,
167 dir: std::path::PathBuf::new(), })
169 }
170}
171
172#[cfg(test)]
173mod tests {
174 use super::*;
175 use crate::skill::{parse_canonical, write_to_dir};
176 use tempfile::tempdir;
177
178 #[test]
179 fn load_all_sets_agent_skill_dir() {
180 let dir = tempdir().unwrap();
181 let home = dir.path();
182 let sdir = home.join("agents").join("a1").join("skills").join("demo");
183 write_to_dir(&sdir, &make("demo")).unwrap();
184
185 let loaded = load_all(home, "a1");
186 let demo = loaded.iter().find(|s| s.name == "demo").unwrap();
187 assert_eq!(demo.dir, sdir);
188 }
189
190 fn make(name: &str) -> SkillManifest {
191 parse_canonical(&format!(
192 r#"name: {name}
193version: 1.0.0
194publisher: human:t
195description: test
196category: context
197content:
198 abstract: hi
199 context: body
200"#
201 ))
202 .unwrap()
203 }
204
205 #[test]
206 fn empty_mur_home_returns_empty() {
207 let dir = tempdir().unwrap();
208 let loaded = load_all(dir.path(), "alice");
209 assert!(loaded.is_empty());
210 }
211
212 #[test]
213 fn load_all_skips_non_skill_dirs() {
214 let dir = tempdir().unwrap();
215 let home = dir.path();
216 write_to_dir(&home.join("skills").join("real"), &make("real")).unwrap();
218 let ledger = home.join("skills").join("not-a-skill");
224 std::fs::create_dir_all(&ledger).unwrap();
225 std::fs::write(ledger.join("events.jsonl"), "{}\n").unwrap();
226
227 let loaded = load_all(home, "a1");
228 let names: Vec<_> = loaded.iter().map(|s| s.name.as_str()).collect();
229 assert_eq!(
230 names,
231 vec!["real"],
232 "ledger dir must not be loaded as a skill"
233 );
234 }
235
236 #[test]
237 fn is_valid_skill_name_rejects_traversal_and_reserved() {
238 assert!(is_valid_skill_name("web-search"));
240 assert!(is_valid_skill_name("my.skill_v2"));
241 assert!(!is_valid_skill_name("."));
243 assert!(!is_valid_skill_name(".."));
244 assert!(!is_valid_skill_name("../agents/victim/skills/evil"));
246 assert!(!is_valid_skill_name("a/b"));
247 assert!(!is_valid_skill_name("a\\b"));
248 assert!(!is_valid_skill_name("/etc/passwd"));
249 assert!(!is_valid_skill_name(""));
251 assert!(!is_valid_skill_name(&"x".repeat(65)));
252 }
253
254 #[test]
255 fn global_skill_returns_sandboxed_when_no_trust_entry() {
256 let dir = tempdir().unwrap();
257 write_to_dir(&dir.path().join("skills").join("demo"), &make("demo")).unwrap();
258 let loaded = load_all(dir.path(), "alice");
259 assert_eq!(loaded.len(), 1);
260 assert_eq!(loaded[0].name, "demo");
261 assert_eq!(loaded[0].trust, TrustLevel::Sandboxed);
262 assert_eq!(loaded[0].scope, SkillScope::Global);
263 }
264
265 #[test]
266 fn agent_overrides_global_by_name() {
267 let dir = tempdir().unwrap();
268 write_to_dir(&dir.path().join("skills").join("shared"), &make("shared")).unwrap();
270 write_to_dir(
271 &dir.path()
272 .join("agents")
273 .join("alice")
274 .join("skills")
275 .join("shared"),
276 &make("shared"),
277 )
278 .unwrap();
279 let loaded = load_all(dir.path(), "alice");
280 let shared: Vec<_> = loaded.iter().filter(|s| s.name == "shared").collect();
281 assert_eq!(shared.len(), 1);
282 assert_eq!(shared[0].scope, SkillScope::Agent);
283 }
284}