1use 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, BoundSkillRegistry, DiscoveryOptions,
19 DiscoveryReport, FsLayerConfig, Layer, LayeredDiscovery, ManifestSource, 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}
52
53pub fn load_skills(inputs: &SkillLoaderInputs) -> LoadedSkills {
56 let mut cfg = FsLayerConfig {
57 cli_dirs: inputs.cli_dirs.clone(),
58 ..FsLayerConfig::default()
59 };
60
61 if let Ok(raw) = std::env::var("HARN_SKILLS_PATH") {
62 if !raw.is_empty() {
63 cfg.env_dirs = parse_env_skills_path(&raw);
64 }
65 }
66
67 if let Some(project_root) = inputs
68 .source_path
69 .as_deref()
70 .and_then(harn_vm::stdlib::process::find_project_root)
71 {
72 cfg.project_root = Some(project_root.clone());
73 cfg.packages_dir = Some(project_root.join(".harn").join("packages"));
74 }
75
76 let resolved = load_skills_config(inputs.source_path.as_deref());
77 let registry_url = resolved
78 .as_ref()
79 .and_then(|resolved| resolved.config.signer_registry_url.clone());
80 let mut options = DiscoveryOptions::default();
81 if let Some(resolved) = resolved.as_ref() {
82 cfg.manifest_paths.extend(resolve_skills_paths(resolved));
83 cfg.manifest_sources
84 .extend(resolved.sources.iter().filter_map(manifest_source_to_vm));
85 apply_option_overrides(&mut options, resolved);
86 }
87
88 cfg.user_dir = default_user_dir();
89 cfg.system_dirs = default_system_dirs();
90
91 let discovery = Arc::new(build_fs_discovery(&cfg, options));
92 let report = discovery.build_report();
93
94 let mut loader_warnings = Vec::new();
95 let mut entries: Vec<VmValue> = Vec::new();
96 for winner in &report.winners {
97 if !winner.unknown_fields.is_empty() {
98 loader_warnings.push(format!(
99 "skills: {} has unknown frontmatter fields: {}",
100 winner.id,
101 winner.unknown_fields.join(", "),
102 ));
103 }
104 let provenance = build_provenance_report_for_ref(winner, registry_url.clone());
110 if let Some(report) = provenance.as_ref() {
111 if matches!(
112 report.status,
113 VerificationStatus::InvalidSignature
114 | VerificationStatus::MissingSigner
115 | VerificationStatus::UntrustedSigner
116 ) {
117 loader_warnings.push(format!(
118 "skills: {} provenance check: {}",
119 winner.id,
120 report.human_summary()
121 ));
122 }
123 }
124 let mut entry = match skill_manifest_ref_to_vm(winner) {
125 VmValue::Dict(map) => (*map).clone(),
126 _ => BTreeMap::new(),
127 };
128 if let Some(report) = provenance {
129 entry.insert("provenance".to_string(), provenance_to_vm(&report));
130 }
131 entries.push(VmValue::Dict(Rc::new(entry)));
132 }
133
134 let mut registry: BTreeMap<String, VmValue> = BTreeMap::new();
135 registry.insert(
136 "_type".to_string(),
137 VmValue::String(Rc::from("skill_registry")),
138 );
139 registry.insert("skills".to_string(), VmValue::List(Rc::new(entries)));
140 let registry_value = VmValue::Dict(Rc::new(registry));
141
142 LoadedSkills {
143 registry: registry_value,
144 report,
145 loader_warnings,
146 discovery,
147 }
148}
149
150fn build_provenance_report_for_ref(
151 winner: &SkillManifestRef,
152 registry_url: Option<String>,
153) -> Option<VerificationReport> {
154 if winner.origin.is_empty() {
155 return None;
156 }
157 let skill_path = PathBuf::from(&winner.origin).join("SKILL.md");
158 let options = VerifyOptions {
159 registry_url,
160 allowed_signers: winner.manifest.trusted_signers.clone(),
161 allowed_endorsers: winner.manifest.trusted_endorsers.clone(),
162 };
163 match skill_provenance::verify_skill(&skill_path, &options) {
164 Ok(report) => Some(report),
165 Err(error) => Some(VerificationReport {
166 skill_path: skill_path.clone(),
167 signature_path: skill_provenance::signature_path_for(&skill_path),
168 skill_sha256: String::new(),
169 signer_fingerprint: None,
170 signed_at: None,
171 endorsements: Vec::new(),
172 signed: false,
173 trusted: false,
174 status: VerificationStatus::InvalidSignature,
175 error: Some(error),
176 }),
177 }
178}
179
180fn provenance_to_vm(report: &VerificationReport) -> VmValue {
181 let mut dict = BTreeMap::new();
182 dict.insert(
183 "skill_sha256".to_string(),
184 VmValue::String(Rc::from(report.skill_sha256.as_str())),
185 );
186 dict.insert("signed".to_string(), VmValue::Bool(report.signed));
187 dict.insert("trusted".to_string(), VmValue::Bool(report.trusted));
188 dict.insert(
189 "status".to_string(),
190 VmValue::String(Rc::from(status_label(report.status))),
191 );
192 dict.insert(
193 "signature_path".to_string(),
194 VmValue::String(Rc::from(report.signature_path.display().to_string())),
195 );
196 if let Some(fingerprint) = report.signer_fingerprint.as_deref() {
197 dict.insert(
198 "signer_fingerprint".to_string(),
199 VmValue::String(Rc::from(fingerprint)),
200 );
201 dict.insert(
202 "author".to_string(),
203 signer_policy_input(fingerprint, report.signed_at.as_deref()),
204 );
205 }
206 let endorsements = report
207 .endorsements
208 .iter()
209 .map(|endorsement| {
210 let mut item = match signer_policy_input(
211 &endorsement.endorser_fingerprint,
212 Some(&endorsement.signed_at),
213 ) {
214 VmValue::Dict(map) => (*map).clone(),
215 _ => BTreeMap::new(),
216 };
217 item.insert("trusted".to_string(), VmValue::Bool(endorsement.trusted));
218 item.insert(
219 "status".to_string(),
220 VmValue::String(Rc::from(status_label(endorsement.status))),
221 );
222 if let Some(error) = endorsement.error.as_deref() {
223 item.insert("error".to_string(), VmValue::String(Rc::from(error)));
224 }
225 VmValue::Dict(Rc::new(item))
226 })
227 .collect();
228 dict.insert(
229 "endorsements".to_string(),
230 VmValue::List(Rc::new(endorsements)),
231 );
232 let mut policy_input = BTreeMap::new();
233 policy_input.insert(
234 "action".to_string(),
235 VmValue::String(Rc::from("skill.provenance")),
236 );
237 if let Some(fingerprint) = report.signer_fingerprint.as_deref() {
238 policy_input.insert(
239 "author_actor_id".to_string(),
240 VmValue::String(Rc::from(fingerprint)),
241 );
242 }
243 policy_input.insert(
244 "endorser_actor_ids".to_string(),
245 VmValue::List(Rc::new(
246 report
247 .endorsements
248 .iter()
249 .map(|endorsement| {
250 VmValue::String(Rc::from(endorsement.endorser_fingerprint.as_str()))
251 })
252 .collect(),
253 )),
254 );
255 dict.insert(
256 "trust_policy_input".to_string(),
257 VmValue::Dict(Rc::new(policy_input)),
258 );
259 if let Some(error) = report.error.as_deref() {
260 dict.insert("error".to_string(), VmValue::String(Rc::from(error)));
261 }
262 VmValue::Dict(Rc::new(dict))
263}
264
265fn signer_policy_input(fingerprint: &str, signed_at: Option<&str>) -> VmValue {
266 let mut dict = BTreeMap::new();
267 dict.insert(
268 "fingerprint".to_string(),
269 VmValue::String(Rc::from(fingerprint)),
270 );
271 dict.insert(
272 "trust_actor_id".to_string(),
273 VmValue::String(Rc::from(fingerprint)),
274 );
275 dict.insert(
276 "trust_action".to_string(),
277 VmValue::String(Rc::from("skill.provenance")),
278 );
279 if let Some(signed_at) = signed_at {
280 dict.insert(
281 "signed_at".to_string(),
282 VmValue::String(Rc::from(signed_at)),
283 );
284 }
285 VmValue::Dict(Rc::new(dict))
286}
287
288fn status_label(status: VerificationStatus) -> &'static str {
289 status.as_str()
290}
291
292fn manifest_source_to_vm(entry: &SkillSourceEntry) -> Option<ManifestSource> {
293 match entry {
294 SkillSourceEntry::Fs { path, namespace } => Some(ManifestSource::Fs {
295 path: PathBuf::from(path),
296 namespace: namespace.clone(),
297 }),
298 SkillSourceEntry::Git {
299 url,
300 tag,
301 namespace,
302 } => {
303 let _ = (url, tag);
312 namespace.as_ref().map(|ns| ManifestSource::Git {
313 path: PathBuf::new(),
314 namespace: Some(ns.clone()),
315 })
316 }
317 SkillSourceEntry::Registry { .. } => None,
318 }
319}
320
321fn apply_option_overrides(options: &mut DiscoveryOptions, resolved: &ResolvedSkillsConfig) {
322 for label in &resolved.config.disable {
323 if let Some(layer) = Layer::from_label(label) {
324 options.disabled_layers.push(layer);
325 }
326 }
327 if !resolved.config.lookup_order.is_empty() {
328 let ordered: Vec<Layer> = resolved
329 .config
330 .lookup_order
331 .iter()
332 .filter_map(|s| Layer::from_label(s))
333 .collect();
334 if !ordered.is_empty() {
335 options.lookup_order = Some(ordered);
336 }
337 }
338}
339
340pub fn install_skills_global(vm: &mut harn_vm::Vm, loaded: &LoadedSkills) {
344 vm.set_global("skills", loaded.registry.clone());
345 let discovery = loaded.discovery.clone();
346 install_current_skill_registry(Some(BoundSkillRegistry {
347 registry: loaded.registry.clone(),
348 fetcher: Arc::new(move |id| discovery.fetch(id)),
349 }));
350}
351
352pub fn emit_loader_warnings(warnings: &[String]) {
355 for w in warnings {
356 eprintln!("warning: {w}");
357 }
358}
359
360pub fn canonicalize_cli_dirs(raw: &[String], cwd: Option<&Path>) -> Vec<PathBuf> {
364 let base = cwd
365 .map(Path::to_path_buf)
366 .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
367 raw.iter()
368 .map(|p| {
369 let candidate = PathBuf::from(p);
370 if candidate.is_absolute() {
371 candidate
372 } else {
373 base.join(candidate)
374 }
375 })
376 .collect()
377}
378
379#[cfg(test)]
380mod tests {
381 use super::*;
382 use std::fs;
383
384 use crate::env_guard::ScopedEnvVar;
385 use crate::skill_provenance;
386 use crate::tests::common::{cwd_lock::lock_cwd, env_lock::lock_env};
387
388 fn write_skill(root: &Path, sub: &str, name: &str, body: &str) {
389 let dir = root.join(sub);
390 fs::create_dir_all(&dir).unwrap();
391 fs::write(
392 dir.join("SKILL.md"),
393 format!("---\nname: {name}\nshort: {name} short card\n---\n{body}"),
394 )
395 .unwrap();
396 }
397
398 fn set_home(path: &Path) -> ScopedEnvVar {
399 ScopedEnvVar::set("HOME", path.to_str().unwrap())
400 }
401
402 #[test]
403 fn cli_dirs_produce_registry_entries() {
404 let tmp = tempfile::tempdir().unwrap();
405 write_skill(tmp.path(), "deploy", "deploy", "body A");
406 let loaded = load_skills(&SkillLoaderInputs {
407 cli_dirs: vec![tmp.path().to_path_buf()],
408 source_path: None,
409 });
410 assert_eq!(loaded.report.winners.len(), 1);
411 assert!(loaded.loader_warnings.is_empty());
412 let VmValue::Dict(registry) = &loaded.registry else {
413 panic!("registry should be a dict");
414 };
415 let VmValue::List(entries) = registry.get("skills").unwrap() else {
416 panic!("skills should be a list");
417 };
418 assert_eq!(entries.len(), 1);
419 let entry = entries[0].as_dict().expect("skill entry should be a dict");
420 assert_eq!(
421 entry.get("short").map(|value| value.display()).as_deref(),
422 Some("deploy short card")
423 );
424 assert!(
425 !entry.contains_key("body"),
426 "startup registry should not eagerly include the full body"
427 );
428 }
429
430 #[test]
431 fn unknown_frontmatter_fields_surface_as_warnings() {
432 let tmp = tempfile::tempdir().unwrap();
433 let dir = tmp.path().join("thing");
434 fs::create_dir_all(&dir).unwrap();
435 fs::write(
436 dir.join("SKILL.md"),
437 "---\nname: thing\nshort: thing short card\nfuture_mystery_field: 42\n---\nbody",
438 )
439 .unwrap();
440 let loaded = load_skills(&SkillLoaderInputs {
441 cli_dirs: vec![tmp.path().to_path_buf()],
442 source_path: None,
443 });
444 assert_eq!(loaded.report.winners.len(), 1);
445 assert!(
446 loaded
447 .loader_warnings
448 .iter()
449 .any(|w| w.contains("future_mystery_field")),
450 "{:?}",
451 loaded.loader_warnings
452 );
453 }
454
455 #[test]
456 fn loader_attaches_verified_provenance_metadata() {
457 let _cwd = lock_cwd();
458 let _env = lock_env().blocking_lock();
459 let tmp = tempfile::tempdir().unwrap();
460 let _home = set_home(tmp.path());
461
462 let skill_dir = tmp.path().join("deploy");
463 fs::create_dir_all(&skill_dir).unwrap();
464 fs::write(
465 skill_dir.join("SKILL.md"),
466 "---\nname: deploy\nshort: deploy short card\nrequire_signature: true\n---\nbody",
467 )
468 .unwrap();
469
470 let keys = skill_provenance::generate_keypair(tmp.path().join("signer.pem")).unwrap();
471 skill_provenance::sign_skill(skill_dir.join("SKILL.md"), &keys.private_key_path).unwrap();
472 skill_provenance::trust_add(keys.public_key_path.to_str().unwrap()).unwrap();
473 let endorser_keys =
474 skill_provenance::generate_keypair(tmp.path().join("endorser.pem")).unwrap();
475 skill_provenance::endorse_skill(
476 skill_dir.join("SKILL.md"),
477 &endorser_keys.private_key_path,
478 )
479 .unwrap();
480 skill_provenance::trust_add(endorser_keys.public_key_path.to_str().unwrap()).unwrap();
481
482 let loaded = load_skills(&SkillLoaderInputs {
483 cli_dirs: vec![tmp.path().to_path_buf()],
484 source_path: None,
485 });
486 let VmValue::Dict(registry) = &loaded.registry else {
487 panic!("registry should be a dict");
488 };
489 let VmValue::List(entries) = registry.get("skills").unwrap() else {
490 panic!("skills should be a list");
491 };
492 let Some(provenance) = entries[0]
493 .as_dict()
494 .and_then(|entry| entry.get("provenance"))
495 .and_then(VmValue::as_dict)
496 else {
497 panic!("provenance should be present");
498 };
499 assert_eq!(
500 provenance.get("signed").map(VmValue::display).as_deref(),
501 Some("true")
502 );
503 assert_eq!(
504 provenance.get("trusted").map(VmValue::display).as_deref(),
505 Some("true")
506 );
507 assert!(
508 loaded.loader_warnings.is_empty(),
509 "{:?}",
510 loaded.loader_warnings
511 );
512 }
513
514 #[test]
515 fn loader_warns_when_signature_is_invalid() {
516 let _cwd = lock_cwd();
517 let _env = lock_env().blocking_lock();
518 let tmp = tempfile::tempdir().unwrap();
519 let _home = set_home(tmp.path());
520
521 let skill_dir = tmp.path().join("deploy");
522 fs::create_dir_all(&skill_dir).unwrap();
523 fs::write(
524 skill_dir.join("SKILL.md"),
525 "---\nname: deploy\nshort: deploy short card\n---\nbody",
526 )
527 .unwrap();
528
529 let keys = skill_provenance::generate_keypair(tmp.path().join("signer.pem")).unwrap();
530 skill_provenance::sign_skill(skill_dir.join("SKILL.md"), &keys.private_key_path).unwrap();
531 fs::write(
532 skill_dir.join("SKILL.md"),
533 "---\nname: deploy\nshort: deploy short card\n---\nbody changed",
534 )
535 .unwrap();
536
537 let loaded = load_skills(&SkillLoaderInputs {
538 cli_dirs: vec![tmp.path().to_path_buf()],
539 source_path: None,
540 });
541 assert!(
542 loaded
543 .loader_warnings
544 .iter()
545 .any(|warning| warning.contains("does not match the current contents")),
546 "{:?}",
547 loaded.loader_warnings
548 );
549 }
550}