use crate::skill::types::TrustLevel;
use crate::skill::{DriftStatus, SkillManifest, content_sha256, drift_status, local};
use crate::trust::skills::SkillTrustStore;
use std::path::Path;
pub fn is_valid_skill_name(name: &str) -> bool {
!name.is_empty()
&& name.len() <= 64
&& name != "."
&& name != ".."
&& name
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-'))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SkillScope {
Global,
Agent,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SkillRefStatus {
Loadable,
Missing { path: std::path::PathBuf },
Malformed {
path: std::path::PathBuf,
error: String,
},
}
pub fn skill_ref_status(agent_home: &Path, rel_ref: &str) -> SkillRefStatus {
let joined = agent_home.join(rel_ref);
let ext = joined
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_ascii_lowercase();
let file = if joined.is_dir() || !matches!(ext.as_str(), "yaml" | "yml" | "md" | "markdown") {
joined.join("skill.yaml")
} else {
joined
};
if !file.is_file() {
return SkillRefStatus::Missing { path: file };
}
let text = match std::fs::read_to_string(&file) {
Ok(t) => t,
Err(e) => {
return SkillRefStatus::Malformed {
path: file,
error: format!("unreadable: {e}"),
};
}
};
let ext = file
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_ascii_lowercase();
let parsed = match ext.as_str() {
"yaml" | "yml" => crate::skill::parse_canonical(&text),
"md" | "markdown" => crate::skill::parse_markdown(&text)
.or_else(|_| crate::skill::parse_legacy_markdown(&text)),
other => {
return SkillRefStatus::Malformed {
path: file,
error: format!("unsupported manifest extension '.{other}'"),
};
}
};
match parsed {
Ok(m) => match crate::skill::validate(&m) {
Ok(()) => SkillRefStatus::Loadable,
Err(e) => SkillRefStatus::Malformed {
path: file,
error: format!("invalid manifest: {e}"),
},
},
Err(e) => SkillRefStatus::Malformed {
path: file,
error: format!("parse failed: {e}"),
},
}
}
#[derive(Debug, Clone)]
pub struct LoadedSkill {
pub name: String,
pub manifest: SkillManifest,
pub trust: TrustLevel,
pub scope: SkillScope,
pub content_hash: String,
pub dir: std::path::PathBuf,
}
pub fn load_all(mur_home: &Path, agent_name: &str) -> Vec<LoadedSkill> {
let trust = SkillTrustStore::load(mur_home).unwrap_or_default();
let mut out: Vec<LoadedSkill> = Vec::new();
let mut seen_names: std::collections::HashSet<String> = Default::default();
if let Ok(names) = local::list_installed_agent(mur_home, agent_name) {
for name in names {
if !crate::skill::store::agent_skill_dir(mur_home, agent_name)
.join(&name)
.join("skill.yaml")
.is_file()
{
continue;
}
if let Some(mut loaded) =
load_one(mur_home, &name, SkillScope::Agent, &trust, |m, n| {
local::load_installed_agent(m, agent_name, n)
})
{
loaded.dir = crate::skill::store::agent_skill_dir(mur_home, agent_name).join(&name);
seen_names.insert(loaded.name.clone());
out.push(loaded);
}
}
}
if let Ok(names) = local::list_installed(mur_home) {
for name in names {
if seen_names.contains(&name) {
continue;
}
if !crate::skill::store::global_skill_dir(mur_home, &name)
.join("skill.yaml")
.is_file()
{
continue;
}
if let Some(mut loaded) = load_one(
mur_home,
&name,
SkillScope::Global,
&trust,
local::load_installed,
) {
loaded.dir = crate::skill::store::global_skill_dir(mur_home, &name);
out.push(loaded);
}
}
}
out
}
fn load_one<F>(
mur_home: &Path,
name: &str,
scope: SkillScope,
trust: &SkillTrustStore,
loader: F,
) -> Option<LoadedSkill>
where
F: FnOnce(&Path, &str) -> Result<SkillManifest, crate::skill::StoreError>,
{
if !is_valid_skill_name(name) {
tracing::warn!(
skill = %name,
"skill name contains invalid characters (expected [A-Za-z0-9_.-]{{1,64}}); skipping"
);
return None;
}
let manifest = match loader(mur_home, name) {
Ok(m) => m,
Err(e) => {
tracing::warn!(skill = %name, error = %e, "skill load failed; skipping");
return None;
}
};
let hash = match content_sha256(&manifest) {
Ok(h) => h,
Err(e) => {
tracing::warn!(skill = %name, error = %e, "skill hash failed; skipping");
return None;
}
};
let entry = trust.entries.get(&hash);
if let Some(pinned) = entry {
if let Ok(DriftStatus::Drift { expected, actual }) = drift_status(&manifest, Some(&hash)) {
tracing::warn!(skill = %name, expected, actual, "skill drift detected; skipping");
return None;
}
if trust.is_revoked(&hash) {
tracing::warn!(skill = %name, "skill hash revoked; skipping");
return None;
}
Some(LoadedSkill {
name: name.into(),
manifest,
trust: pinned.level,
scope,
content_hash: hash,
dir: std::path::PathBuf::new(), })
} else {
Some(LoadedSkill {
name: name.into(),
manifest,
trust: TrustLevel::Sandboxed,
scope,
content_hash: hash,
dir: std::path::PathBuf::new(), })
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::skill::{parse_canonical, write_to_dir};
use tempfile::tempdir;
#[test]
fn load_all_sets_agent_skill_dir() {
let dir = tempdir().unwrap();
let home = dir.path();
let sdir = home.join("agents").join("a1").join("skills").join("demo");
write_to_dir(&sdir, &make("demo")).unwrap();
let loaded = load_all(home, "a1");
let demo = loaded.iter().find(|s| s.name == "demo").unwrap();
assert_eq!(demo.dir, sdir);
}
fn make(name: &str) -> SkillManifest {
parse_canonical(&format!(
r#"name: {name}
version: 1.0.0
publisher: human:t
description: test
category: context
content:
abstract: hi
context: body
"#
))
.unwrap()
}
#[test]
fn empty_mur_home_returns_empty() {
let dir = tempdir().unwrap();
let loaded = load_all(dir.path(), "alice");
assert!(loaded.is_empty());
}
#[test]
fn load_all_skips_non_skill_dirs() {
let dir = tempdir().unwrap();
let home = dir.path();
write_to_dir(&home.join("skills").join("real"), &make("real")).unwrap();
let ledger = home.join("skills").join("not-a-skill");
std::fs::create_dir_all(&ledger).unwrap();
std::fs::write(ledger.join("events.jsonl"), "{}\n").unwrap();
let loaded = load_all(home, "a1");
let names: Vec<_> = loaded.iter().map(|s| s.name.as_str()).collect();
assert_eq!(
names,
vec!["real"],
"ledger dir must not be loaded as a skill"
);
}
#[test]
fn is_valid_skill_name_rejects_traversal_and_reserved() {
assert!(is_valid_skill_name("web-search"));
assert!(is_valid_skill_name("my.skill_v2"));
assert!(!is_valid_skill_name("."));
assert!(!is_valid_skill_name(".."));
assert!(!is_valid_skill_name("../agents/victim/skills/evil"));
assert!(!is_valid_skill_name("a/b"));
assert!(!is_valid_skill_name("a\\b"));
assert!(!is_valid_skill_name("/etc/passwd"));
assert!(!is_valid_skill_name(""));
assert!(!is_valid_skill_name(&"x".repeat(65)));
}
#[test]
fn global_skill_returns_sandboxed_when_no_trust_entry() {
let dir = tempdir().unwrap();
write_to_dir(&dir.path().join("skills").join("demo"), &make("demo")).unwrap();
let loaded = load_all(dir.path(), "alice");
assert_eq!(loaded.len(), 1);
assert_eq!(loaded[0].name, "demo");
assert_eq!(loaded[0].trust, TrustLevel::Sandboxed);
assert_eq!(loaded[0].scope, SkillScope::Global);
}
#[test]
fn agent_overrides_global_by_name() {
let dir = tempdir().unwrap();
write_to_dir(&dir.path().join("skills").join("shared"), &make("shared")).unwrap();
write_to_dir(
&dir.path()
.join("agents")
.join("alice")
.join("skills")
.join("shared"),
&make("shared"),
)
.unwrap();
let loaded = load_all(dir.path(), "alice");
let shared: Vec<_> = loaded.iter().filter(|s| s.name == "shared").collect();
assert_eq!(shared.len(), 1);
assert_eq!(shared[0].scope, SkillScope::Agent);
}
#[test]
fn skill_ref_status_loadable_for_installed_dir_skill() {
let home = tempdir().unwrap();
write_to_dir(&home.path().join("skills").join("demo"), &make("demo")).unwrap();
assert_eq!(
skill_ref_status(home.path(), "skills/demo"),
SkillRefStatus::Loadable
);
}
#[test]
fn skill_ref_status_absent_ref_is_missing_with_manifest_path() {
let home = tempdir().unwrap();
match skill_ref_status(home.path(), "skills/executing-plans") {
SkillRefStatus::Missing { path } => {
assert!(path.ends_with("skills/executing-plans/skill.yaml"));
}
other => panic!("expected Missing, got {other:?}"),
}
}
#[test]
fn skill_ref_status_garbage_yaml_is_malformed() {
let home = tempdir().unwrap();
let sdir = home.path().join("skills").join("broken");
std::fs::create_dir_all(&sdir).unwrap();
std::fs::write(sdir.join("skill.yaml"), "{{{ not: [valid").unwrap();
assert!(matches!(
skill_ref_status(home.path(), "skills/broken"),
SkillRefStatus::Malformed { .. }
));
}
#[test]
fn skill_ref_status_legacy_md_file_resolves_directly() {
let home = tempdir().unwrap();
let sdir = home.path().join("skills");
std::fs::create_dir_all(&sdir).unwrap();
match skill_ref_status(home.path(), "skills/old.md") {
SkillRefStatus::Missing { path } => assert!(path.ends_with("skills/old.md")),
other => panic!("expected Missing, got {other:?}"),
}
std::fs::write(sdir.join("old.md"), "no frontmatter here").unwrap();
assert!(matches!(
skill_ref_status(home.path(), "skills/old.md"),
SkillRefStatus::Malformed { .. }
));
}
}