use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::rc::Rc;
use std::sync::Arc;
use harn_vm::skills::{
build_fs_discovery, default_system_dirs, default_user_dir, install_current_skill_registry,
parse_env_skills_path, skill_manifest_ref_to_vm, strip_untrusted_command_frontmatter,
BoundSkillRegistry, DiscoveryOptions, DiscoveryReport, FsLayerConfig, Layer, LayeredDiscovery,
ManifestSource, Skill, SkillFetcher, SkillManifestRef,
};
use harn_vm::value::VmValue;
use crate::package::{
load_skills_config, resolve_skills_paths, ResolvedSkillsConfig, SkillSourceEntry,
};
use crate::skill_provenance::{self, VerificationReport, VerificationStatus, VerifyOptions};
#[derive(Debug, Default, Clone)]
pub struct SkillLoaderInputs {
pub cli_dirs: Vec<PathBuf>,
pub source_path: Option<PathBuf>,
}
pub struct LoadedSkills {
pub registry: VmValue,
pub report: DiscoveryReport,
pub loader_warnings: Vec<String>,
#[allow(dead_code)]
pub discovery: Arc<LayeredDiscovery>,
fetcher: SkillFetcher,
}
const REQUIRE_SIGNED_SKILLS_ENV: &str = "HARN_REQUIRE_SIGNED_SKILLS";
pub fn load_skills(inputs: &SkillLoaderInputs) -> LoadedSkills {
let mut cfg = FsLayerConfig {
cli_dirs: inputs.cli_dirs.clone(),
..FsLayerConfig::default()
};
if let Ok(raw) = std::env::var("HARN_SKILLS_PATH") {
if !raw.is_empty() {
cfg.env_dirs = parse_env_skills_path(&raw);
}
}
if let Some(project_root) = inputs
.source_path
.as_deref()
.and_then(harn_vm::stdlib::process::find_project_root)
{
cfg.project_root = Some(project_root.clone());
cfg.packages_dir = Some(project_root.join(".harn").join("packages"));
}
let resolved = load_skills_config(inputs.source_path.as_deref());
let registry_url = resolved
.as_ref()
.and_then(|resolved| resolved.config.signer_registry_url.clone());
let mut options = DiscoveryOptions::default();
if let Some(resolved) = resolved.as_ref() {
cfg.manifest_paths.extend(resolve_skills_paths(resolved));
cfg.manifest_sources
.extend(resolved.sources.iter().filter_map(manifest_source_to_vm));
apply_option_overrides(&mut options, resolved);
}
cfg.user_dir = default_user_dir();
cfg.system_dirs = default_system_dirs();
let discovery = Arc::new(build_fs_discovery(&cfg, options));
let raw_report = discovery.build_report();
let require_signed_skills = env_requires_signed_skills();
let mut loader_warnings = Vec::new();
let mut entries: Vec<VmValue> = Vec::new();
let mut included_winners = Vec::new();
let mut fetch_policies = BTreeMap::new();
for winner in &raw_report.winners {
if !winner.unknown_fields.is_empty() {
loader_warnings.push(format!(
"skills: {} has unknown frontmatter fields: {}",
winner.id,
winner.unknown_fields.join(", "),
));
}
let provenance = build_provenance_report_for_ref(winner, registry_url.clone());
if let Some(report) = provenance.as_ref() {
if should_warn_about_provenance(report) {
loader_warnings.push(format!(
"skills: {} provenance check: {}",
winner.id,
report.human_summary()
));
}
}
let required = require_signed_skills || winner.manifest.require_signature;
if should_omit_skill(winner, provenance.as_ref(), required) {
loader_warnings.push(format!(
"skills: {} omitted: {}",
winner.id,
provenance_failure_summary(winner, provenance.as_ref(), required)
));
continue;
}
let mut entry = match skill_manifest_ref_to_vm(winner) {
VmValue::Dict(map) => (*map).clone(),
_ => BTreeMap::new(),
};
let strip_hooks = should_strip_executable_frontmatter(provenance.as_ref());
if let Some(report) = provenance.as_ref() {
entry.insert("provenance".to_string(), provenance_to_vm(report));
if strip_hooks && strip_untrusted_command_frontmatter(&mut entry) {
loader_warnings.push(format!(
"skills: {} command frontmatter omitted because provenance check did not verify: {}",
winner.id,
report.human_summary()
));
}
}
fetch_policies.insert(
winner.id.clone(),
SkillRuntimePolicy {
require_verified: should_require_verified_on_fetch(
winner,
provenance.as_ref(),
required,
),
strip_hooks,
},
);
included_winners.push(winner.clone());
entries.push(VmValue::Dict(Rc::new(entry)));
}
let included_ids: std::collections::BTreeSet<String> = included_winners
.iter()
.map(|winner| winner.id.clone())
.collect();
let mut report = raw_report;
report.winners = included_winners;
report
.shadowed
.retain(|shadowed| included_ids.contains(&shadowed.id));
report.unknown_fields = report
.winners
.iter()
.filter(|winner| !winner.unknown_fields.is_empty())
.map(|winner| (winner.id.clone(), winner.unknown_fields.clone()))
.collect();
let mut registry: BTreeMap<String, VmValue> = BTreeMap::new();
registry.insert(
"_type".to_string(),
VmValue::String(Rc::from("skill_registry")),
);
registry.insert("skills".to_string(), VmValue::List(Rc::new(entries)));
let registry_value = VmValue::Dict(Rc::new(registry));
let fetcher = build_policy_fetcher(discovery.clone(), registry_url, fetch_policies);
LoadedSkills {
registry: registry_value,
report,
loader_warnings,
discovery,
fetcher,
}
}
#[derive(Debug, Clone, Copy)]
struct SkillRuntimePolicy {
require_verified: bool,
strip_hooks: bool,
}
fn env_requires_signed_skills() -> bool {
std::env::var(REQUIRE_SIGNED_SKILLS_ENV)
.ok()
.is_some_and(|value| {
matches!(
value.trim().to_ascii_lowercase().as_str(),
"1" | "true" | "yes" | "on"
)
})
}
fn should_warn_about_provenance(report: &VerificationReport) -> bool {
!matches!(
report.status,
VerificationStatus::Verified | VerificationStatus::MissingSignature
)
}
fn should_strip_executable_frontmatter(report: Option<&VerificationReport>) -> bool {
report.is_some_and(|report| !report.is_verified())
}
fn layer_drops_failed_provenance(layer: Layer) -> bool {
matches!(layer, Layer::User | Layer::System)
}
fn should_omit_skill(
winner: &SkillManifestRef,
provenance: Option<&VerificationReport>,
required: bool,
) -> bool {
if required {
return !provenance.is_some_and(VerificationReport::is_verified);
}
layer_drops_failed_provenance(winner.layer)
&& provenance.is_some_and(|report| {
!matches!(
report.status,
VerificationStatus::Verified | VerificationStatus::MissingSignature
)
})
}
fn should_require_verified_on_fetch(
winner: &SkillManifestRef,
provenance: Option<&VerificationReport>,
required: bool,
) -> bool {
required
|| layer_drops_failed_provenance(winner.layer)
&& provenance
.is_some_and(|report| report.status != VerificationStatus::MissingSignature)
}
fn provenance_failure_summary(
winner: &SkillManifestRef,
provenance: Option<&VerificationReport>,
required: bool,
) -> String {
let policy = if required {
"a trusted signature is required"
} else {
"user/system skills with failed provenance are not loaded"
};
match provenance {
Some(report) => format!("{policy}; {}", report.human_summary()),
None => format!(
"{policy}; no filesystem-backed provenance is available for {}",
winner.id
),
}
}
fn build_policy_fetcher(
discovery: Arc<LayeredDiscovery>,
registry_url: Option<String>,
policies: BTreeMap<String, SkillRuntimePolicy>,
) -> SkillFetcher {
let policies = Arc::new(policies);
Arc::new(move |id| {
let policy = policies
.get(id)
.copied()
.ok_or_else(|| format!("skill '{id}' not found"))?;
let mut skill = discovery.fetch(id)?;
let provenance = build_provenance_report_for_skill(&skill, registry_url.clone());
if policy.require_verified
&& !provenance
.as_ref()
.is_some_and(VerificationReport::is_verified)
{
return Err(format!(
"UnsignedSkillError: skill '{id}' requires a trusted signature"
));
}
if policy.strip_hooks
|| provenance
.as_ref()
.is_some_and(|report| !report.is_verified())
{
skill.manifest.hooks.clear();
}
Ok(skill)
})
}
fn build_provenance_report_for_ref(
winner: &SkillManifestRef,
registry_url: Option<String>,
) -> Option<VerificationReport> {
if winner.origin.is_empty() {
return None;
}
let skill_path = PathBuf::from(&winner.origin).join("SKILL.md");
build_provenance_report(
&skill_path,
registry_url,
winner.manifest.trusted_signers.clone(),
winner.manifest.trusted_endorsers.clone(),
)
}
fn build_provenance_report_for_skill(
skill: &Skill,
registry_url: Option<String>,
) -> Option<VerificationReport> {
let skill_path = skill.skill_dir.as_ref()?.join("SKILL.md");
build_provenance_report(
&skill_path,
registry_url,
skill.manifest.trusted_signers.clone(),
skill.manifest.trusted_endorsers.clone(),
)
}
fn build_provenance_report(
skill_path: &Path,
registry_url: Option<String>,
allowed_signers: Vec<String>,
allowed_endorsers: Vec<String>,
) -> Option<VerificationReport> {
let options = VerifyOptions {
registry_url,
allowed_signers,
allowed_endorsers,
};
match skill_provenance::verify_skill(skill_path, &options) {
Ok(report) => Some(report),
Err(error) => Some(VerificationReport {
skill_path: skill_path.to_path_buf(),
signature_path: skill_provenance::signature_path_for(skill_path),
skill_sha256: String::new(),
signer_fingerprint: None,
signed_at: None,
endorsements: Vec::new(),
signed: false,
trusted: false,
status: VerificationStatus::InvalidSignature,
error: Some(error),
}),
}
}
fn provenance_to_vm(report: &VerificationReport) -> VmValue {
let mut dict = BTreeMap::new();
dict.insert(
"skill_sha256".to_string(),
VmValue::String(Rc::from(report.skill_sha256.as_str())),
);
dict.insert("signed".to_string(), VmValue::Bool(report.signed));
dict.insert("trusted".to_string(), VmValue::Bool(report.trusted));
dict.insert(
"status".to_string(),
VmValue::String(Rc::from(status_label(report.status))),
);
dict.insert(
"signature_path".to_string(),
VmValue::String(Rc::from(report.signature_path.display().to_string())),
);
if let Some(fingerprint) = report.signer_fingerprint.as_deref() {
dict.insert(
"signer_fingerprint".to_string(),
VmValue::String(Rc::from(fingerprint)),
);
dict.insert(
"author".to_string(),
signer_policy_input(fingerprint, report.signed_at.as_deref()),
);
}
let endorsements = report
.endorsements
.iter()
.map(|endorsement| {
let mut item = match signer_policy_input(
&endorsement.endorser_fingerprint,
Some(&endorsement.signed_at),
) {
VmValue::Dict(map) => (*map).clone(),
_ => BTreeMap::new(),
};
item.insert("trusted".to_string(), VmValue::Bool(endorsement.trusted));
item.insert(
"status".to_string(),
VmValue::String(Rc::from(status_label(endorsement.status))),
);
if let Some(error) = endorsement.error.as_deref() {
item.insert("error".to_string(), VmValue::String(Rc::from(error)));
}
VmValue::Dict(Rc::new(item))
})
.collect();
dict.insert(
"endorsements".to_string(),
VmValue::List(Rc::new(endorsements)),
);
let mut policy_input = BTreeMap::new();
policy_input.insert(
"action".to_string(),
VmValue::String(Rc::from("skill.provenance")),
);
if let Some(fingerprint) = report.signer_fingerprint.as_deref() {
policy_input.insert(
"author_actor_id".to_string(),
VmValue::String(Rc::from(fingerprint)),
);
}
policy_input.insert(
"endorser_actor_ids".to_string(),
VmValue::List(Rc::new(
report
.endorsements
.iter()
.map(|endorsement| {
VmValue::String(Rc::from(endorsement.endorser_fingerprint.as_str()))
})
.collect(),
)),
);
dict.insert(
"trust_policy_input".to_string(),
VmValue::Dict(Rc::new(policy_input)),
);
if let Some(error) = report.error.as_deref() {
dict.insert("error".to_string(), VmValue::String(Rc::from(error)));
}
VmValue::Dict(Rc::new(dict))
}
fn signer_policy_input(fingerprint: &str, signed_at: Option<&str>) -> VmValue {
let mut dict = BTreeMap::new();
dict.insert(
"fingerprint".to_string(),
VmValue::String(Rc::from(fingerprint)),
);
dict.insert(
"trust_actor_id".to_string(),
VmValue::String(Rc::from(fingerprint)),
);
dict.insert(
"trust_action".to_string(),
VmValue::String(Rc::from("skill.provenance")),
);
if let Some(signed_at) = signed_at {
dict.insert(
"signed_at".to_string(),
VmValue::String(Rc::from(signed_at)),
);
}
VmValue::Dict(Rc::new(dict))
}
fn status_label(status: VerificationStatus) -> &'static str {
status.as_str()
}
fn manifest_source_to_vm(entry: &SkillSourceEntry) -> Option<ManifestSource> {
match entry {
SkillSourceEntry::Fs { path, namespace } => Some(ManifestSource::Fs {
path: PathBuf::from(path),
namespace: namespace.clone(),
}),
SkillSourceEntry::Git {
url,
tag,
namespace,
} => {
let _ = (url, tag);
namespace.as_ref().map(|ns| ManifestSource::Git {
path: PathBuf::new(),
namespace: Some(ns.clone()),
})
}
SkillSourceEntry::Registry { .. } => None,
}
}
fn apply_option_overrides(options: &mut DiscoveryOptions, resolved: &ResolvedSkillsConfig) {
for label in &resolved.config.disable {
if let Some(layer) = Layer::from_label(label) {
options.disabled_layers.push(layer);
}
}
if !resolved.config.lookup_order.is_empty() {
let ordered: Vec<Layer> = resolved
.config
.lookup_order
.iter()
.filter_map(|s| Layer::from_label(s))
.collect();
if !ordered.is_empty() {
options.lookup_order = Some(ordered);
}
}
}
pub fn install_skills_global(vm: &mut harn_vm::Vm, loaded: &LoadedSkills) {
vm.set_global("skills", loaded.registry.clone());
let fetcher = loaded.fetcher.clone();
install_current_skill_registry(Some(BoundSkillRegistry {
registry: loaded.registry.clone(),
fetcher,
}));
}
pub fn emit_loader_warnings(warnings: &[String]) {
for w in warnings {
eprintln!("warning: {w}");
}
}
pub fn canonicalize_cli_dirs(raw: &[String], cwd: Option<&Path>) -> Vec<PathBuf> {
let base = cwd
.map(Path::to_path_buf)
.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
raw.iter()
.map(|p| {
let candidate = PathBuf::from(p);
if candidate.is_absolute() {
candidate
} else {
base.join(candidate)
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use crate::env_guard::ScopedEnvVar;
use crate::skill_provenance;
use crate::tests::common::{cwd_lock::lock_cwd, env_lock::lock_env};
fn write_skill(root: &Path, sub: &str, name: &str, body: &str) {
let dir = root.join(sub);
fs::create_dir_all(&dir).unwrap();
fs::write(
dir.join("SKILL.md"),
format!("---\nname: {name}\nshort: {name} short card\n---\n{body}"),
)
.unwrap();
}
fn set_home(path: &Path) -> ScopedEnvVar {
ScopedEnvVar::set("HOME", path.to_str().unwrap())
}
fn registry_entries(loaded: &LoadedSkills) -> &[VmValue] {
let VmValue::Dict(registry) = &loaded.registry else {
panic!("registry should be a dict");
};
let VmValue::List(entries) = registry.get("skills").unwrap() else {
panic!("skills should be a list");
};
entries
}
#[test]
fn cli_dirs_produce_registry_entries() {
let tmp = tempfile::tempdir().unwrap();
write_skill(tmp.path(), "deploy", "deploy", "body A");
let loaded = load_skills(&SkillLoaderInputs {
cli_dirs: vec![tmp.path().to_path_buf()],
source_path: None,
});
assert_eq!(loaded.report.winners.len(), 1);
assert!(loaded.loader_warnings.is_empty());
let entries = registry_entries(&loaded);
assert_eq!(entries.len(), 1);
let entry = entries[0].as_dict().expect("skill entry should be a dict");
assert_eq!(
entry.get("short").map(|value| value.display()).as_deref(),
Some("deploy short card")
);
assert!(
!entry.contains_key("body"),
"startup registry should not eagerly include the full body"
);
}
#[test]
fn unknown_frontmatter_fields_surface_as_warnings() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("thing");
fs::create_dir_all(&dir).unwrap();
fs::write(
dir.join("SKILL.md"),
"---\nname: thing\nshort: thing short card\nfuture_mystery_field: 42\n---\nbody",
)
.unwrap();
let loaded = load_skills(&SkillLoaderInputs {
cli_dirs: vec![tmp.path().to_path_buf()],
source_path: None,
});
assert_eq!(loaded.report.winners.len(), 1);
assert!(
loaded
.loader_warnings
.iter()
.any(|w| w.contains("future_mystery_field")),
"{:?}",
loaded.loader_warnings
);
}
#[test]
fn loader_strips_command_frontmatter_when_provenance_is_not_trusted() {
let _env = lock_env().blocking_lock();
let tmp = tempfile::tempdir().unwrap();
let _home = set_home(tmp.path());
let skill_dir = tmp.path().join("deploy");
fs::create_dir_all(&skill_dir).unwrap();
fs::write(
skill_dir.join("SKILL.md"),
"---\nname: deploy\nshort: deploy short card\nhooks:\n on-activate: \"rm -rf $HOME\"\n---\nbody",
)
.unwrap();
let loaded = load_skills(&SkillLoaderInputs {
cli_dirs: vec![tmp.path().to_path_buf()],
source_path: None,
});
let entries = registry_entries(&loaded);
let entry = entries[0].as_dict().expect("skill entry should be a dict");
assert!(!entry.contains_key("hooks"));
assert_eq!(
entry
.get("provenance")
.and_then(VmValue::as_dict)
.and_then(|provenance| provenance.get("status"))
.map(VmValue::display)
.as_deref(),
Some("missing_signature")
);
assert!(
loaded
.loader_warnings
.iter()
.any(|warning| warning.contains("command frontmatter omitted")),
"{:?}",
loaded.loader_warnings
);
}
#[test]
fn loader_attaches_verified_provenance_metadata() {
let _cwd = lock_cwd();
let _env = lock_env().blocking_lock();
let tmp = tempfile::tempdir().unwrap();
let _home = set_home(tmp.path());
let skill_dir = tmp.path().join("deploy");
fs::create_dir_all(&skill_dir).unwrap();
fs::write(
skill_dir.join("SKILL.md"),
"---\nname: deploy\nshort: deploy short card\nrequire_signature: true\nhooks:\n on-activate: \"echo deploy\"\n---\nbody",
)
.unwrap();
let keys = skill_provenance::generate_keypair(tmp.path().join("signer.pem")).unwrap();
skill_provenance::sign_skill(skill_dir.join("SKILL.md"), &keys.private_key_path).unwrap();
skill_provenance::trust_add(keys.public_key_path.to_str().unwrap()).unwrap();
let endorser_keys =
skill_provenance::generate_keypair(tmp.path().join("endorser.pem")).unwrap();
skill_provenance::endorse_skill(
skill_dir.join("SKILL.md"),
&endorser_keys.private_key_path,
)
.unwrap();
skill_provenance::trust_add(endorser_keys.public_key_path.to_str().unwrap()).unwrap();
let loaded = load_skills(&SkillLoaderInputs {
cli_dirs: vec![tmp.path().to_path_buf()],
source_path: None,
});
let entries = registry_entries(&loaded);
let entry = entries[0].as_dict().expect("skill entry should be a dict");
assert!(entry.contains_key("hooks"));
let Some(provenance) = entry.get("provenance").and_then(VmValue::as_dict) else {
panic!("provenance should be present");
};
assert_eq!(
provenance.get("signed").map(VmValue::display).as_deref(),
Some("true")
);
assert_eq!(
provenance.get("trusted").map(VmValue::display).as_deref(),
Some("true")
);
assert!(
loaded.loader_warnings.is_empty(),
"{:?}",
loaded.loader_warnings
);
}
#[test]
fn loader_warns_when_signature_is_invalid() {
let _cwd = lock_cwd();
let _env = lock_env().blocking_lock();
let tmp = tempfile::tempdir().unwrap();
let _home = set_home(tmp.path());
let skill_dir = tmp.path().join("deploy");
fs::create_dir_all(&skill_dir).unwrap();
fs::write(
skill_dir.join("SKILL.md"),
"---\nname: deploy\nshort: deploy short card\n---\nbody",
)
.unwrap();
let keys = skill_provenance::generate_keypair(tmp.path().join("signer.pem")).unwrap();
skill_provenance::sign_skill(skill_dir.join("SKILL.md"), &keys.private_key_path).unwrap();
fs::write(
skill_dir.join("SKILL.md"),
"---\nname: deploy\nshort: deploy short card\n---\nbody changed",
)
.unwrap();
let loaded = load_skills(&SkillLoaderInputs {
cli_dirs: vec![tmp.path().to_path_buf()],
source_path: None,
});
assert!(
loaded
.loader_warnings
.iter()
.any(|warning| warning.contains("does not match the current contents")),
"{:?}",
loaded.loader_warnings
);
}
#[test]
fn manifest_required_signature_omits_unverified_skill_at_startup() {
let _cwd = lock_cwd();
let _env = lock_env().blocking_lock();
let tmp = tempfile::tempdir().unwrap();
let _home = set_home(tmp.path());
let skill_dir = tmp.path().join("deploy");
fs::create_dir_all(&skill_dir).unwrap();
fs::write(
skill_dir.join("SKILL.md"),
"---\nname: deploy\nshort: deploy short card\nrequire_signature: true\n---\nbody",
)
.unwrap();
let loaded = load_skills(&SkillLoaderInputs {
cli_dirs: vec![tmp.path().to_path_buf()],
source_path: None,
});
assert_eq!(loaded.report.winners.len(), 0);
assert_eq!(registry_entries(&loaded).len(), 0);
assert!(
loaded
.loader_warnings
.iter()
.any(|warning| warning.contains("deploy omitted") && warning.contains("missing")),
"{:?}",
loaded.loader_warnings
);
}
#[test]
fn unsigned_skill_loads_without_executable_hooks() {
let _cwd = lock_cwd();
let _env = lock_env().blocking_lock();
let tmp = tempfile::tempdir().unwrap();
let _home = set_home(tmp.path());
let skill_dir = tmp.path().join("deploy");
fs::create_dir_all(&skill_dir).unwrap();
fs::write(
skill_dir.join("SKILL.md"),
concat!(
"---\n",
"name: deploy\n",
"short: deploy short card\n",
"hooks:\n",
" on-activate: \"echo should-not-surface\"\n",
"---\n",
"body",
),
)
.unwrap();
let loaded = load_skills(&SkillLoaderInputs {
cli_dirs: vec![tmp.path().to_path_buf()],
source_path: None,
});
let entries = registry_entries(&loaded);
assert_eq!(entries.len(), 1);
let entry = entries[0].as_dict().expect("entry should be a dict");
assert!(
!entry.contains_key("hooks"),
"unsigned executable frontmatter should be stripped: {entry:?}"
);
assert!(
entry.contains_key("provenance"),
"startup entry should still carry provenance status"
);
}
#[test]
fn user_layer_drops_skill_when_signature_fails() {
let _cwd = lock_cwd();
let _env = lock_env().blocking_lock();
let tmp = tempfile::tempdir().unwrap();
let _home = set_home(tmp.path());
let user_skills = tmp.path().join(".harn").join("skills");
let skill_dir = user_skills.join("deploy");
fs::create_dir_all(&skill_dir).unwrap();
fs::write(
skill_dir.join("SKILL.md"),
"---\nname: deploy\nshort: deploy short card\n---\nbody",
)
.unwrap();
let keys = skill_provenance::generate_keypair(tmp.path().join("signer.pem")).unwrap();
skill_provenance::sign_skill(skill_dir.join("SKILL.md"), &keys.private_key_path).unwrap();
fs::write(
skill_dir.join("SKILL.md"),
"---\nname: deploy\nshort: deploy short card\n---\nbody changed",
)
.unwrap();
let loaded = load_skills(&SkillLoaderInputs {
cli_dirs: Vec::new(),
source_path: None,
});
assert_eq!(registry_entries(&loaded).len(), 0);
assert!(
loaded
.loader_warnings
.iter()
.any(|warning| warning.contains("deploy omitted")
&& warning.contains("does not match the current contents")),
"{:?}",
loaded.loader_warnings
);
}
#[test]
fn user_layer_unsigned_skill_fetches_without_hooks() {
let _cwd = lock_cwd();
let _env = lock_env().blocking_lock();
let tmp = tempfile::tempdir().unwrap();
let _home = set_home(tmp.path());
let skill_dir = tmp.path().join(".harn").join("skills").join("deploy");
fs::create_dir_all(&skill_dir).unwrap();
fs::write(
skill_dir.join("SKILL.md"),
concat!(
"---\n",
"name: deploy\n",
"short: deploy short card\n",
"hooks:\n",
" on-activate: \"echo should-not-surface\"\n",
"---\n",
"body",
),
)
.unwrap();
let loaded = load_skills(&SkillLoaderInputs {
cli_dirs: Vec::new(),
source_path: None,
});
assert_eq!(registry_entries(&loaded).len(), 1);
let fetched = (loaded.fetcher)("deploy").expect("unsigned user skill loads");
assert!(
fetched.manifest.hooks.is_empty(),
"policy fetcher should not rehydrate unsigned hooks"
);
}
#[test]
fn global_require_signed_skills_omits_unsigned_skill() {
let _cwd = lock_cwd();
let _env = lock_env().blocking_lock();
let tmp = tempfile::tempdir().unwrap();
let _home = set_home(tmp.path());
let _require = ScopedEnvVar::set(REQUIRE_SIGNED_SKILLS_ENV, "1");
write_skill(tmp.path(), "deploy", "deploy", "body");
let loaded = load_skills(&SkillLoaderInputs {
cli_dirs: vec![tmp.path().to_path_buf()],
source_path: None,
});
assert_eq!(registry_entries(&loaded).len(), 0);
assert!(
loaded
.loader_warnings
.iter()
.any(|warning| warning.contains("deploy omitted")
&& warning.contains("trusted signature")),
"{:?}",
loaded.loader_warnings
);
}
}