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