Skip to main content

harn_cli/
skill_loader.rs

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