1use std::collections::BTreeMap;
12use std::path::{Path, PathBuf};
13use std::sync::Arc;
14
15use harn_vm::skills::{
16 build_fs_discovery, default_system_dirs, default_user_dir, install_current_skill_registry,
17 parse_env_skills_path, skill_manifest_ref_to_vm, strip_untrusted_command_frontmatter,
18 BoundSkillRegistry, DiscoveryOptions, DiscoveryReport, FsLayerConfig, Layer, LayeredDiscovery,
19 ManifestSource, Skill, SkillFetcher, SkillManifestRef,
20};
21use harn_vm::value::VmValue;
22
23use crate::package::{
24 load_skills_config, resolve_skills_paths, ResolvedSkillsConfig, SkillSourceEntry,
25};
26use crate::skill_provenance::{self, VerificationReport, VerificationStatus, VerifyOptions};
27
28#[derive(Debug, Default, Clone)]
32pub struct SkillLoaderInputs {
33 pub cli_dirs: Vec<PathBuf>,
34 pub source_path: Option<PathBuf>,
35}
36
37pub struct LoadedSkills {
43 pub registry: VmValue,
44 pub report: DiscoveryReport,
45 pub loader_warnings: Vec<String>,
46 #[allow(dead_code)]
50 pub discovery: Arc<LayeredDiscovery>,
51 fetcher: SkillFetcher,
52}
53
54const REQUIRE_SIGNED_SKILLS_ENV: &str = "HARN_REQUIRE_SIGNED_SKILLS";
55
56pub fn load_skills(inputs: &SkillLoaderInputs) -> LoadedSkills {
59 let mut cfg = FsLayerConfig {
60 cli_dirs: inputs.cli_dirs.clone(),
61 ..FsLayerConfig::default()
62 };
63
64 if let Ok(raw) = std::env::var("HARN_SKILLS_PATH") {
65 if !raw.is_empty() {
66 cfg.env_dirs = parse_env_skills_path(&raw);
67 }
68 }
69
70 if let Some(project_root) = inputs
71 .source_path
72 .as_deref()
73 .and_then(harn_vm::stdlib::process::find_project_root)
74 {
75 cfg.project_root = Some(project_root.clone());
76 cfg.packages_dir = Some(project_root.join(".harn").join("packages"));
77 }
78
79 let resolved = load_skills_config(inputs.source_path.as_deref());
80 let registry_url = resolved
81 .as_ref()
82 .and_then(|resolved| resolved.config.signer_registry_url.clone());
83 let mut options = DiscoveryOptions::default();
84 if let Some(resolved) = resolved.as_ref() {
85 cfg.manifest_paths.extend(resolve_skills_paths(resolved));
86 cfg.manifest_sources
87 .extend(resolved.sources.iter().filter_map(manifest_source_to_vm));
88 apply_option_overrides(&mut options, resolved);
89 }
90
91 cfg.user_dir = default_user_dir();
92 cfg.system_dirs = default_system_dirs();
93
94 let discovery = Arc::new(build_fs_discovery(&cfg, options));
95 let raw_report = discovery.build_report();
96 let require_signed_skills = env_requires_signed_skills();
97
98 let mut loader_warnings = Vec::new();
99 let mut entries: Vec<VmValue> = Vec::new();
100 let mut included_winners = Vec::new();
101 let mut fetch_policies = BTreeMap::new();
102 for winner in &raw_report.winners {
103 if !winner.unknown_fields.is_empty() {
104 loader_warnings.push(format!(
105 "skills: {} has unknown frontmatter fields: {}",
106 winner.id,
107 winner.unknown_fields.join(", "),
108 ));
109 }
110 let provenance = build_provenance_report_for_ref(winner, registry_url.clone());
116 if let Some(report) = provenance.as_ref() {
117 if should_warn_about_provenance(report) {
118 loader_warnings.push(format!(
119 "skills: {} provenance check: {}",
120 winner.id,
121 report.human_summary()
122 ));
123 }
124 }
125 let required = require_signed_skills || winner.manifest.require_signature;
126 if should_omit_skill(winner, provenance.as_ref(), required) {
127 loader_warnings.push(format!(
128 "skills: {} omitted: {}",
129 winner.id,
130 provenance_failure_summary(winner, provenance.as_ref(), required)
131 ));
132 continue;
133 }
134 let mut entry = match skill_manifest_ref_to_vm(winner) {
135 VmValue::Dict(map) => (*map).clone(),
136 _ => BTreeMap::new(),
137 };
138 let strip_hooks = should_strip_executable_frontmatter(provenance.as_ref());
139 if let Some(report) = provenance.as_ref() {
140 entry.insert("provenance".to_string(), provenance_to_vm(report));
141 if strip_hooks && strip_untrusted_command_frontmatter(&mut entry) {
142 loader_warnings.push(format!(
143 "skills: {} command frontmatter omitted because provenance check did not verify: {}",
144 winner.id,
145 report.human_summary()
146 ));
147 }
148 }
149 fetch_policies.insert(
150 winner.id.clone(),
151 SkillRuntimePolicy {
152 require_verified: should_require_verified_on_fetch(
153 winner,
154 provenance.as_ref(),
155 required,
156 ),
157 strip_hooks,
158 },
159 );
160 included_winners.push(winner.clone());
161 entries.push(VmValue::Dict(std::sync::Arc::new(entry)));
162 }
163
164 let included_ids: std::collections::BTreeSet<String> = included_winners
165 .iter()
166 .map(|winner| winner.id.clone())
167 .collect();
168 let mut report = raw_report;
169 report.winners = included_winners;
170 report
171 .shadowed
172 .retain(|shadowed| included_ids.contains(&shadowed.id));
173 report.unknown_fields = report
174 .winners
175 .iter()
176 .filter(|winner| !winner.unknown_fields.is_empty())
177 .map(|winner| (winner.id.clone(), winner.unknown_fields.clone()))
178 .collect();
179
180 let mut registry: BTreeMap<String, VmValue> = BTreeMap::new();
181 registry.insert(
182 "_type".to_string(),
183 VmValue::String(std::sync::Arc::from("skill_registry")),
184 );
185 registry.insert(
186 "skills".to_string(),
187 VmValue::List(std::sync::Arc::new(entries)),
188 );
189 let registry_value = VmValue::Dict(std::sync::Arc::new(registry));
190 let fetcher = build_policy_fetcher(discovery.clone(), registry_url, fetch_policies);
191
192 LoadedSkills {
193 registry: registry_value,
194 report,
195 loader_warnings,
196 discovery,
197 fetcher,
198 }
199}
200
201#[derive(Debug, Clone, Copy)]
202struct SkillRuntimePolicy {
203 require_verified: bool,
204 strip_hooks: bool,
205}
206
207fn env_requires_signed_skills() -> bool {
208 std::env::var(REQUIRE_SIGNED_SKILLS_ENV)
209 .ok()
210 .is_some_and(|value| {
211 matches!(
212 value.trim().to_ascii_lowercase().as_str(),
213 "1" | "true" | "yes" | "on"
214 )
215 })
216}
217
218fn should_warn_about_provenance(report: &VerificationReport) -> bool {
219 !matches!(
220 report.status,
221 VerificationStatus::Verified | VerificationStatus::MissingSignature
222 )
223}
224
225fn should_strip_executable_frontmatter(report: Option<&VerificationReport>) -> bool {
226 report.is_some_and(|report| !report.is_verified())
227}
228
229fn layer_drops_failed_provenance(layer: Layer) -> bool {
230 matches!(layer, Layer::User | Layer::System)
231}
232
233fn should_omit_skill(
234 winner: &SkillManifestRef,
235 provenance: Option<&VerificationReport>,
236 required: bool,
237) -> bool {
238 if required {
239 return !provenance.is_some_and(VerificationReport::is_verified);
240 }
241 layer_drops_failed_provenance(winner.layer)
242 && provenance.is_some_and(|report| {
243 !matches!(
244 report.status,
245 VerificationStatus::Verified | VerificationStatus::MissingSignature
246 )
247 })
248}
249
250fn should_require_verified_on_fetch(
251 winner: &SkillManifestRef,
252 provenance: Option<&VerificationReport>,
253 required: bool,
254) -> bool {
255 required
256 || layer_drops_failed_provenance(winner.layer)
257 && provenance
258 .is_some_and(|report| report.status != VerificationStatus::MissingSignature)
259}
260
261fn provenance_failure_summary(
262 winner: &SkillManifestRef,
263 provenance: Option<&VerificationReport>,
264 required: bool,
265) -> String {
266 let policy = if required {
267 "a trusted signature is required"
268 } else {
269 "user/system skills with failed provenance are not loaded"
270 };
271 match provenance {
272 Some(report) => format!("{policy}; {}", report.human_summary()),
273 None => format!(
274 "{policy}; no filesystem-backed provenance is available for {}",
275 winner.id
276 ),
277 }
278}
279
280fn build_policy_fetcher(
281 discovery: Arc<LayeredDiscovery>,
282 registry_url: Option<String>,
283 policies: BTreeMap<String, SkillRuntimePolicy>,
284) -> SkillFetcher {
285 let policies = Arc::new(policies);
286 Arc::new(move |id| {
287 let policy = policies
288 .get(id)
289 .copied()
290 .ok_or_else(|| format!("skill '{id}' not found"))?;
291 let mut skill = discovery.fetch(id)?;
292 let provenance = build_provenance_report_for_skill(&skill, registry_url.clone());
293 if policy.require_verified
294 && !provenance
295 .as_ref()
296 .is_some_and(VerificationReport::is_verified)
297 {
298 return Err(format!(
299 "UnsignedSkillError: skill '{id}' requires a trusted signature"
300 ));
301 }
302 if policy.strip_hooks
303 || provenance
304 .as_ref()
305 .is_some_and(|report| !report.is_verified())
306 {
307 skill.manifest.hooks.clear();
308 }
309 Ok(skill)
310 })
311}
312
313fn build_provenance_report_for_ref(
314 winner: &SkillManifestRef,
315 registry_url: Option<String>,
316) -> Option<VerificationReport> {
317 if winner.origin.is_empty() {
318 return None;
319 }
320 let skill_path = PathBuf::from(&winner.origin).join("SKILL.md");
321 build_provenance_report(
322 &skill_path,
323 registry_url,
324 winner.manifest.trusted_signers.clone(),
325 winner.manifest.trusted_endorsers.clone(),
326 )
327}
328
329fn build_provenance_report_for_skill(
330 skill: &Skill,
331 registry_url: Option<String>,
332) -> Option<VerificationReport> {
333 let skill_path = skill.skill_dir.as_ref()?.join("SKILL.md");
334 build_provenance_report(
335 &skill_path,
336 registry_url,
337 skill.manifest.trusted_signers.clone(),
338 skill.manifest.trusted_endorsers.clone(),
339 )
340}
341
342fn build_provenance_report(
343 skill_path: &Path,
344 registry_url: Option<String>,
345 allowed_signers: Vec<String>,
346 allowed_endorsers: Vec<String>,
347) -> Option<VerificationReport> {
348 let options = VerifyOptions {
349 registry_url,
350 allowed_signers,
351 allowed_endorsers,
352 };
353 match skill_provenance::verify_skill(skill_path, &options) {
354 Ok(report) => Some(report),
355 Err(error) => Some(VerificationReport {
356 skill_path: skill_path.to_path_buf(),
357 signature_path: skill_provenance::signature_path_for(skill_path),
358 skill_sha256: String::new(),
359 signer_fingerprint: None,
360 signed_at: None,
361 endorsements: Vec::new(),
362 signed: false,
363 trusted: false,
364 status: VerificationStatus::InvalidSignature,
365 error: Some(error),
366 }),
367 }
368}
369
370fn provenance_to_vm(report: &VerificationReport) -> VmValue {
371 let mut dict = BTreeMap::new();
372 dict.insert(
373 "skill_sha256".to_string(),
374 VmValue::String(std::sync::Arc::from(report.skill_sha256.as_str())),
375 );
376 dict.insert("signed".to_string(), VmValue::Bool(report.signed));
377 dict.insert("trusted".to_string(), VmValue::Bool(report.trusted));
378 dict.insert(
379 "status".to_string(),
380 VmValue::String(std::sync::Arc::from(status_label(report.status))),
381 );
382 dict.insert(
383 "signature_path".to_string(),
384 VmValue::String(std::sync::Arc::from(
385 report.signature_path.display().to_string(),
386 )),
387 );
388 if let Some(fingerprint) = report.signer_fingerprint.as_deref() {
389 dict.insert(
390 "signer_fingerprint".to_string(),
391 VmValue::String(std::sync::Arc::from(fingerprint)),
392 );
393 dict.insert(
394 "author".to_string(),
395 signer_policy_input(fingerprint, report.signed_at.as_deref()),
396 );
397 }
398 let endorsements = report
399 .endorsements
400 .iter()
401 .map(|endorsement| {
402 let mut item = match signer_policy_input(
403 &endorsement.endorser_fingerprint,
404 Some(&endorsement.signed_at),
405 ) {
406 VmValue::Dict(map) => (*map).clone(),
407 _ => BTreeMap::new(),
408 };
409 item.insert("trusted".to_string(), VmValue::Bool(endorsement.trusted));
410 item.insert(
411 "status".to_string(),
412 VmValue::String(std::sync::Arc::from(status_label(endorsement.status))),
413 );
414 if let Some(error) = endorsement.error.as_deref() {
415 item.insert(
416 "error".to_string(),
417 VmValue::String(std::sync::Arc::from(error)),
418 );
419 }
420 VmValue::Dict(std::sync::Arc::new(item))
421 })
422 .collect();
423 dict.insert(
424 "endorsements".to_string(),
425 VmValue::List(std::sync::Arc::new(endorsements)),
426 );
427 let mut policy_input = BTreeMap::new();
428 policy_input.insert(
429 "action".to_string(),
430 VmValue::String(std::sync::Arc::from("skill.provenance")),
431 );
432 if let Some(fingerprint) = report.signer_fingerprint.as_deref() {
433 policy_input.insert(
434 "author_actor_id".to_string(),
435 VmValue::String(std::sync::Arc::from(fingerprint)),
436 );
437 }
438 policy_input.insert(
439 "endorser_actor_ids".to_string(),
440 VmValue::List(std::sync::Arc::new(
441 report
442 .endorsements
443 .iter()
444 .map(|endorsement| {
445 VmValue::String(std::sync::Arc::from(
446 endorsement.endorser_fingerprint.as_str(),
447 ))
448 })
449 .collect(),
450 )),
451 );
452 dict.insert(
453 "trust_policy_input".to_string(),
454 VmValue::Dict(std::sync::Arc::new(policy_input)),
455 );
456 if let Some(error) = report.error.as_deref() {
457 dict.insert(
458 "error".to_string(),
459 VmValue::String(std::sync::Arc::from(error)),
460 );
461 }
462 VmValue::Dict(std::sync::Arc::new(dict))
463}
464
465fn signer_policy_input(fingerprint: &str, signed_at: Option<&str>) -> VmValue {
466 let mut dict = BTreeMap::new();
467 dict.insert(
468 "fingerprint".to_string(),
469 VmValue::String(std::sync::Arc::from(fingerprint)),
470 );
471 dict.insert(
472 "trust_actor_id".to_string(),
473 VmValue::String(std::sync::Arc::from(fingerprint)),
474 );
475 dict.insert(
476 "trust_action".to_string(),
477 VmValue::String(std::sync::Arc::from("skill.provenance")),
478 );
479 if let Some(signed_at) = signed_at {
480 dict.insert(
481 "signed_at".to_string(),
482 VmValue::String(std::sync::Arc::from(signed_at)),
483 );
484 }
485 VmValue::Dict(std::sync::Arc::new(dict))
486}
487
488fn status_label(status: VerificationStatus) -> &'static str {
489 status.as_str()
490}
491
492fn manifest_source_to_vm(entry: &SkillSourceEntry) -> Option<ManifestSource> {
493 match entry {
494 SkillSourceEntry::Fs { path, namespace } => Some(ManifestSource::Fs {
495 path: PathBuf::from(path),
496 namespace: namespace.clone(),
497 }),
498 SkillSourceEntry::Git {
499 url,
500 tag,
501 namespace,
502 } => {
503 let _ = (url, tag);
512 namespace.as_ref().map(|ns| ManifestSource::Git {
513 path: PathBuf::new(),
514 namespace: Some(ns.clone()),
515 })
516 }
517 SkillSourceEntry::Registry { .. } => None,
518 }
519}
520
521fn apply_option_overrides(options: &mut DiscoveryOptions, resolved: &ResolvedSkillsConfig) {
522 for label in &resolved.config.disable {
523 if let Some(layer) = Layer::from_label(label) {
524 options.disabled_layers.push(layer);
525 }
526 }
527 if !resolved.config.lookup_order.is_empty() {
528 let ordered: Vec<Layer> = resolved
529 .config
530 .lookup_order
531 .iter()
532 .filter_map(|s| Layer::from_label(s))
533 .collect();
534 if !ordered.is_empty() {
535 options.lookup_order = Some(ordered);
536 }
537 }
538}
539
540pub fn install_skills_global(vm: &mut harn_vm::Vm, loaded: &LoadedSkills) {
544 vm.set_global("skills", loaded.registry.clone());
545 let fetcher = loaded.fetcher.clone();
546 install_current_skill_registry(Some(BoundSkillRegistry {
547 registry: loaded.registry.clone(),
548 fetcher,
549 }));
550}
551
552pub fn emit_loader_warnings(warnings: &[String]) {
555 for w in warnings {
556 eprintln!("warning: {w}");
557 }
558}
559
560pub fn canonicalize_cli_dirs(raw: &[String], cwd: Option<&Path>) -> Vec<PathBuf> {
564 let base = cwd
565 .map(Path::to_path_buf)
566 .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
567 raw.iter()
568 .map(|p| {
569 let candidate = PathBuf::from(p);
570 if candidate.is_absolute() {
571 candidate
572 } else {
573 base.join(candidate)
574 }
575 })
576 .collect()
577}
578
579#[cfg(test)]
580mod tests {
581 use super::*;
582 use std::fs;
583
584 use crate::env_guard::ScopedEnvVar;
585 use crate::skill_provenance;
586 use crate::tests::common::{cwd_lock::lock_cwd, env_lock::lock_env};
587
588 fn write_skill(root: &Path, sub: &str, name: &str, body: &str) {
589 let dir = root.join(sub);
590 fs::create_dir_all(&dir).unwrap();
591 fs::write(
592 dir.join("SKILL.md"),
593 format!("---\nname: {name}\nshort: {name} short card\n---\n{body}"),
594 )
595 .unwrap();
596 }
597
598 fn set_home(path: &Path) -> ScopedEnvVar {
599 ScopedEnvVar::set("HOME", path.to_str().unwrap())
600 }
601
602 fn registry_entries(loaded: &LoadedSkills) -> &[VmValue] {
603 let VmValue::Dict(registry) = &loaded.registry else {
604 panic!("registry should be a dict");
605 };
606 let VmValue::List(entries) = registry.get("skills").unwrap() else {
607 panic!("skills should be a list");
608 };
609 entries
610 }
611
612 #[test]
613 fn cli_dirs_produce_registry_entries() {
614 let tmp = tempfile::tempdir().unwrap();
615 write_skill(tmp.path(), "deploy", "deploy", "body A");
616 let loaded = load_skills(&SkillLoaderInputs {
617 cli_dirs: vec![tmp.path().to_path_buf()],
618 source_path: None,
619 });
620 assert_eq!(loaded.report.winners.len(), 1);
621 assert!(loaded.loader_warnings.is_empty());
622 let entries = registry_entries(&loaded);
623 assert_eq!(entries.len(), 1);
624 let entry = entries[0].as_dict().expect("skill entry should be a dict");
625 assert_eq!(
626 entry.get("short").map(|value| value.display()).as_deref(),
627 Some("deploy short card")
628 );
629 assert!(
630 !entry.contains_key("body"),
631 "startup registry should not eagerly include the full body"
632 );
633 }
634
635 #[test]
636 fn unknown_frontmatter_fields_surface_as_warnings() {
637 let tmp = tempfile::tempdir().unwrap();
638 let dir = tmp.path().join("thing");
639 fs::create_dir_all(&dir).unwrap();
640 fs::write(
641 dir.join("SKILL.md"),
642 "---\nname: thing\nshort: thing short card\nfuture_mystery_field: 42\n---\nbody",
643 )
644 .unwrap();
645 let loaded = load_skills(&SkillLoaderInputs {
646 cli_dirs: vec![tmp.path().to_path_buf()],
647 source_path: None,
648 });
649 assert_eq!(loaded.report.winners.len(), 1);
650 assert!(
651 loaded
652 .loader_warnings
653 .iter()
654 .any(|w| w.contains("future_mystery_field")),
655 "{:?}",
656 loaded.loader_warnings
657 );
658 }
659
660 #[test]
661 fn loader_strips_command_frontmatter_when_provenance_is_not_trusted() {
662 let _env = lock_env().blocking_lock();
663 let tmp = tempfile::tempdir().unwrap();
664 let _home = set_home(tmp.path());
665
666 let skill_dir = tmp.path().join("deploy");
667 fs::create_dir_all(&skill_dir).unwrap();
668 fs::write(
669 skill_dir.join("SKILL.md"),
670 "---\nname: deploy\nshort: deploy short card\nhooks:\n on-activate: \"rm -rf $HOME\"\n---\nbody",
671 )
672 .unwrap();
673
674 let loaded = load_skills(&SkillLoaderInputs {
675 cli_dirs: vec![tmp.path().to_path_buf()],
676 source_path: None,
677 });
678 let entries = registry_entries(&loaded);
679 let entry = entries[0].as_dict().expect("skill entry should be a dict");
680
681 assert!(!entry.contains_key("hooks"));
682 assert_eq!(
683 entry
684 .get("provenance")
685 .and_then(VmValue::as_dict)
686 .and_then(|provenance| provenance.get("status"))
687 .map(VmValue::display)
688 .as_deref(),
689 Some("missing_signature")
690 );
691 assert!(
692 loaded
693 .loader_warnings
694 .iter()
695 .any(|warning| warning.contains("command frontmatter omitted")),
696 "{:?}",
697 loaded.loader_warnings
698 );
699 }
700
701 #[test]
702 fn loader_attaches_verified_provenance_metadata() {
703 let _cwd = lock_cwd();
704 let _env = lock_env().blocking_lock();
705 let tmp = tempfile::tempdir().unwrap();
706 let _home = set_home(tmp.path());
707
708 let skill_dir = tmp.path().join("deploy");
709 fs::create_dir_all(&skill_dir).unwrap();
710 fs::write(
711 skill_dir.join("SKILL.md"),
712 "---\nname: deploy\nshort: deploy short card\nrequire_signature: true\nhooks:\n on-activate: \"echo deploy\"\n---\nbody",
713 )
714 .unwrap();
715
716 let keys = skill_provenance::generate_keypair(tmp.path().join("signer.pem")).unwrap();
717 skill_provenance::sign_skill(skill_dir.join("SKILL.md"), &keys.private_key_path).unwrap();
718 skill_provenance::trust_add(keys.public_key_path.to_str().unwrap()).unwrap();
719 let endorser_keys =
720 skill_provenance::generate_keypair(tmp.path().join("endorser.pem")).unwrap();
721 skill_provenance::endorse_skill(
722 skill_dir.join("SKILL.md"),
723 &endorser_keys.private_key_path,
724 )
725 .unwrap();
726 skill_provenance::trust_add(endorser_keys.public_key_path.to_str().unwrap()).unwrap();
727
728 let loaded = load_skills(&SkillLoaderInputs {
729 cli_dirs: vec![tmp.path().to_path_buf()],
730 source_path: None,
731 });
732 let entries = registry_entries(&loaded);
733 let entry = entries[0].as_dict().expect("skill entry should be a dict");
734 assert!(entry.contains_key("hooks"));
735 let Some(provenance) = entry.get("provenance").and_then(VmValue::as_dict) else {
736 panic!("provenance should be present");
737 };
738 assert_eq!(
739 provenance.get("signed").map(VmValue::display).as_deref(),
740 Some("true")
741 );
742 assert_eq!(
743 provenance.get("trusted").map(VmValue::display).as_deref(),
744 Some("true")
745 );
746 assert!(
747 loaded.loader_warnings.is_empty(),
748 "{:?}",
749 loaded.loader_warnings
750 );
751 }
752
753 #[test]
754 fn loader_warns_when_signature_is_invalid() {
755 let _cwd = lock_cwd();
756 let _env = lock_env().blocking_lock();
757 let tmp = tempfile::tempdir().unwrap();
758 let _home = set_home(tmp.path());
759
760 let skill_dir = tmp.path().join("deploy");
761 fs::create_dir_all(&skill_dir).unwrap();
762 fs::write(
763 skill_dir.join("SKILL.md"),
764 "---\nname: deploy\nshort: deploy short card\n---\nbody",
765 )
766 .unwrap();
767
768 let keys = skill_provenance::generate_keypair(tmp.path().join("signer.pem")).unwrap();
769 skill_provenance::sign_skill(skill_dir.join("SKILL.md"), &keys.private_key_path).unwrap();
770 fs::write(
771 skill_dir.join("SKILL.md"),
772 "---\nname: deploy\nshort: deploy short card\n---\nbody changed",
773 )
774 .unwrap();
775
776 let loaded = load_skills(&SkillLoaderInputs {
777 cli_dirs: vec![tmp.path().to_path_buf()],
778 source_path: None,
779 });
780 assert!(
781 loaded
782 .loader_warnings
783 .iter()
784 .any(|warning| warning.contains("does not match the current contents")),
785 "{:?}",
786 loaded.loader_warnings
787 );
788 }
789
790 #[test]
791 fn manifest_required_signature_omits_unverified_skill_at_startup() {
792 let _cwd = lock_cwd();
793 let _env = lock_env().blocking_lock();
794 let tmp = tempfile::tempdir().unwrap();
795 let _home = set_home(tmp.path());
796
797 let skill_dir = tmp.path().join("deploy");
798 fs::create_dir_all(&skill_dir).unwrap();
799 fs::write(
800 skill_dir.join("SKILL.md"),
801 "---\nname: deploy\nshort: deploy short card\nrequire_signature: true\n---\nbody",
802 )
803 .unwrap();
804
805 let loaded = load_skills(&SkillLoaderInputs {
806 cli_dirs: vec![tmp.path().to_path_buf()],
807 source_path: None,
808 });
809 assert_eq!(loaded.report.winners.len(), 0);
810 assert_eq!(registry_entries(&loaded).len(), 0);
811 assert!(
812 loaded
813 .loader_warnings
814 .iter()
815 .any(|warning| warning.contains("deploy omitted") && warning.contains("missing")),
816 "{:?}",
817 loaded.loader_warnings
818 );
819 }
820
821 #[test]
822 fn unsigned_skill_loads_without_executable_hooks() {
823 let _cwd = lock_cwd();
824 let _env = lock_env().blocking_lock();
825 let tmp = tempfile::tempdir().unwrap();
826 let _home = set_home(tmp.path());
827
828 let skill_dir = tmp.path().join("deploy");
829 fs::create_dir_all(&skill_dir).unwrap();
830 fs::write(
831 skill_dir.join("SKILL.md"),
832 concat!(
833 "---\n",
834 "name: deploy\n",
835 "short: deploy short card\n",
836 "hooks:\n",
837 " on-activate: \"echo should-not-surface\"\n",
838 "---\n",
839 "body",
840 ),
841 )
842 .unwrap();
843
844 let loaded = load_skills(&SkillLoaderInputs {
845 cli_dirs: vec![tmp.path().to_path_buf()],
846 source_path: None,
847 });
848 let entries = registry_entries(&loaded);
849 assert_eq!(entries.len(), 1);
850 let entry = entries[0].as_dict().expect("entry should be a dict");
851 assert!(
852 !entry.contains_key("hooks"),
853 "unsigned executable frontmatter should be stripped: {entry:?}"
854 );
855 assert!(
856 entry.contains_key("provenance"),
857 "startup entry should still carry provenance status"
858 );
859 }
860
861 #[test]
862 fn user_layer_drops_skill_when_signature_fails() {
863 let _cwd = lock_cwd();
864 let _env = lock_env().blocking_lock();
865 let tmp = tempfile::tempdir().unwrap();
866 let _home = set_home(tmp.path());
867
868 let user_skills = tmp.path().join(".harn").join("skills");
869 let skill_dir = user_skills.join("deploy");
870 fs::create_dir_all(&skill_dir).unwrap();
871 fs::write(
872 skill_dir.join("SKILL.md"),
873 "---\nname: deploy\nshort: deploy short card\n---\nbody",
874 )
875 .unwrap();
876
877 let keys = skill_provenance::generate_keypair(tmp.path().join("signer.pem")).unwrap();
878 skill_provenance::sign_skill(skill_dir.join("SKILL.md"), &keys.private_key_path).unwrap();
879 fs::write(
880 skill_dir.join("SKILL.md"),
881 "---\nname: deploy\nshort: deploy short card\n---\nbody changed",
882 )
883 .unwrap();
884
885 let loaded = load_skills(&SkillLoaderInputs {
886 cli_dirs: Vec::new(),
887 source_path: None,
888 });
889 assert_eq!(registry_entries(&loaded).len(), 0);
890 assert!(
891 loaded
892 .loader_warnings
893 .iter()
894 .any(|warning| warning.contains("deploy omitted")
895 && warning.contains("does not match the current contents")),
896 "{:?}",
897 loaded.loader_warnings
898 );
899 }
900
901 #[test]
902 fn user_layer_unsigned_skill_fetches_without_hooks() {
903 let _cwd = lock_cwd();
904 let _env = lock_env().blocking_lock();
905 let tmp = tempfile::tempdir().unwrap();
906 let _home = set_home(tmp.path());
907
908 let skill_dir = tmp.path().join(".harn").join("skills").join("deploy");
909 fs::create_dir_all(&skill_dir).unwrap();
910 fs::write(
911 skill_dir.join("SKILL.md"),
912 concat!(
913 "---\n",
914 "name: deploy\n",
915 "short: deploy short card\n",
916 "hooks:\n",
917 " on-activate: \"echo should-not-surface\"\n",
918 "---\n",
919 "body",
920 ),
921 )
922 .unwrap();
923
924 let loaded = load_skills(&SkillLoaderInputs {
925 cli_dirs: Vec::new(),
926 source_path: None,
927 });
928 assert_eq!(registry_entries(&loaded).len(), 1);
929 let fetched = (loaded.fetcher)("deploy").expect("unsigned user skill loads");
930 assert!(
931 fetched.manifest.hooks.is_empty(),
932 "policy fetcher should not rehydrate unsigned hooks"
933 );
934 }
935
936 #[test]
937 fn global_require_signed_skills_omits_unsigned_skill() {
938 let _cwd = lock_cwd();
939 let _env = lock_env().blocking_lock();
940 let tmp = tempfile::tempdir().unwrap();
941 let _home = set_home(tmp.path());
942 let _require = ScopedEnvVar::set(REQUIRE_SIGNED_SKILLS_ENV, "1");
943 write_skill(tmp.path(), "deploy", "deploy", "body");
944
945 let loaded = load_skills(&SkillLoaderInputs {
946 cli_dirs: vec![tmp.path().to_path_buf()],
947 source_path: None,
948 });
949 assert_eq!(registry_entries(&loaded).len(), 0);
950 assert!(
951 loaded
952 .loader_warnings
953 .iter()
954 .any(|warning| warning.contains("deploy omitted")
955 && warning.contains("trusted signature")),
956 "{:?}",
957 loaded.loader_warnings
958 );
959 }
960}