Skip to main content

harn_vm/skills/
runtime.rs

1use std::cell::RefCell;
2use std::sync::Arc;
3
4use crate::value::{ErrorCategory, VmError, VmValue};
5
6use super::{
7    skill_entry_to_vm, strip_untrusted_command_frontmatter, substitute_skill_body, Skill,
8    SubstitutionContext,
9};
10
11pub type SkillFetcher = Arc<dyn Fn(&str) -> Result<Skill, String> + Send + Sync>;
12
13#[derive(Clone)]
14pub struct BoundSkillRegistry {
15    pub registry: VmValue,
16    pub fetcher: SkillFetcher,
17}
18
19pub struct LoadedSkill {
20    pub id: String,
21    pub entry: crate::value::DictMap,
22    pub rendered_body: String,
23}
24
25#[derive(Debug, Clone, Default)]
26pub struct LoadSkillOptions {
27    pub session_id: Option<String>,
28    pub require_signature: bool,
29    pub model_invocation: bool,
30}
31
32const EXECUTABLE_FRONTMATTER_FIELDS: &[&str] = &["hooks", "command", "run"];
33
34thread_local! {
35    static CURRENT_SKILL_REGISTRY: RefCell<Option<BoundSkillRegistry>> = const { RefCell::new(None) };
36}
37
38pub fn install_current_skill_registry(
39    binding: Option<BoundSkillRegistry>,
40) -> Option<BoundSkillRegistry> {
41    CURRENT_SKILL_REGISTRY.with(|slot| slot.replace(binding))
42}
43
44pub fn current_skill_registry() -> Option<BoundSkillRegistry> {
45    CURRENT_SKILL_REGISTRY.with(|slot| slot.borrow().clone())
46}
47
48pub fn clear_current_skill_registry() {
49    CURRENT_SKILL_REGISTRY.with(|slot| {
50        *slot.borrow_mut() = None;
51    });
52}
53
54pub fn skill_entry_id(entry: &crate::value::DictMap) -> String {
55    let name = entry.get("name").map(|v| v.display()).unwrap_or_default();
56    let namespace = entry
57        .get("namespace")
58        .map(|v| v.display())
59        .filter(|value| !value.is_empty());
60    match namespace {
61        Some(ns) => format!("{ns}/{name}"),
62        None => name,
63    }
64}
65
66/// Priority of a skill entry's `source` layer for collision resolution.
67/// Lower is higher priority (matches [`super::Layer`]'s `Ord`: `cli` <
68/// `project` < `user` < `host`). Entries without a recognizable `source`
69/// sort last so a labelled entry always wins a tie against an unlabelled one,
70/// and equal/absent labels fall back to scan order (first wins).
71fn skill_source_priority(entry: &crate::value::DictMap) -> usize {
72    entry
73        .get("source")
74        .map(|value| value.display())
75        .and_then(|label| super::Layer::from_label(&label))
76        .map(|layer| {
77            super::Layer::all()
78                .iter()
79                .position(|candidate| *candidate == layer)
80                .unwrap_or(usize::MAX)
81        })
82        .unwrap_or(usize::MAX)
83}
84
85pub fn resolve_skill_entry(
86    registry: &VmValue,
87    target: &str,
88    builtin_name: &str,
89) -> Result<crate::value::DictMap, String> {
90    let dict = registry
91        .as_dict()
92        .ok_or_else(|| format!("{builtin_name}: bound skill registry is not a dict"))?;
93    let skills = match dict.get("skills") {
94        Some(VmValue::List(list)) => list,
95        _ => {
96            return Err(format!("{builtin_name}: bound skill registry is malformed"));
97        }
98    };
99
100    // A fully-qualified id (`namespace/name`) is an exact, unambiguous match
101    // and always wins. Collect bare-name matches separately so a non-namespaced
102    // collision does not short-circuit on the first scan-order entry (whose id
103    // equals its bare name) before precedence can be applied.
104    let mut bare_matches: Vec<crate::value::DictMap> = Vec::new();
105    for skill in skills.iter() {
106        let Some(entry) = skill.as_dict() else {
107            continue;
108        };
109        let has_namespace = entry
110            .get("namespace")
111            .map(|value| value.display())
112            .is_some_and(|namespace| !namespace.is_empty());
113        if has_namespace && skill_entry_id(entry) == target {
114            return Ok(entry.clone());
115        }
116        if entry
117            .get("name")
118            .map(|value| value.display())
119            .is_some_and(|name| name == target)
120        {
121            bare_matches.push(entry.clone());
122        }
123    }
124
125    // Hosts collapse name collisions by precedence before building the
126    // registry, so normally there is at most one bare-name match. When two
127    // survive anyway (e.g. a host that did not collapse), resolve
128    // deterministically by `source` layer priority instead of erroring — the
129    // same project > user > host precedence the hosts apply. This keeps
130    // `load_skill` resolution identical to the catalog the model sees, rather
131    // than failing the turn.
132    match bare_matches.len() {
133        0 => Err(format!("skill_not_found: skill '{target}' not found")),
134        1 => Ok(bare_matches.remove(0)),
135        _ => {
136            let mut best_index = 0;
137            for (index, entry) in bare_matches.iter().enumerate() {
138                if skill_source_priority(entry) < skill_source_priority(&bare_matches[best_index]) {
139                    best_index = index;
140                }
141            }
142            Ok(bare_matches.swap_remove(best_index))
143        }
144    }
145}
146
147fn entry_has_inline_body(entry: &crate::value::DictMap) -> bool {
148    entry
149        .get("body")
150        .map(|value| value.display())
151        .as_ref()
152        .is_some_and(|value| !value.is_empty())
153        || entry
154            .get("prompt")
155            .map(|value| value.display())
156            .as_ref()
157            .is_some_and(|value| !value.is_empty())
158}
159
160fn body_from_entry(entry: &crate::value::DictMap) -> String {
161    entry
162        .get("body")
163        .map(|value| value.display())
164        .filter(|value| !value.is_empty())
165        .or_else(|| {
166            entry
167                .get("prompt")
168                .map(|value| value.display())
169                .filter(|value| !value.is_empty())
170        })
171        .unwrap_or_default()
172}
173
174fn hydrate_skill_entry(
175    entry: crate::value::DictMap,
176    fetcher: Option<&SkillFetcher>,
177    builtin_name: &str,
178) -> Result<crate::value::DictMap, String> {
179    if entry_has_inline_body(&entry) {
180        return Ok(entry);
181    }
182
183    let skill_id = skill_entry_id(&entry);
184    let Some(fetcher) = fetcher else {
185        return Err(format!(
186            "{builtin_name}: skill '{skill_id}' is not lazily loadable in this scope"
187        ));
188    };
189
190    let loaded = fetcher(&skill_id)?;
191    match skill_entry_to_vm(&loaded) {
192        VmValue::Dict(dict) => {
193            let mut hydrated = (*dict).clone();
194            // Loader-side provenance checks may redact executable metadata
195            // from the startup registry. Lazy body hydration must not restore
196            // those fields from the raw SKILL.md.
197            for field in EXECUTABLE_FRONTMATTER_FIELDS {
198                if !entry.contains_key(*field) {
199                    hydrated.remove(*field);
200                }
201            }
202            for (key, value) in entry {
203                hydrated.entry(key).or_insert(value);
204            }
205            strip_untrusted_command_frontmatter(&mut hydrated);
206            Ok(hydrated)
207        }
208        _ => Err(format!(
209            "{builtin_name}: failed to hydrate skill '{skill_id}'"
210        )),
211    }
212}
213
214fn render_skill_entry(entry: &crate::value::DictMap, session_id: Option<&str>) -> String {
215    let skill_dir = entry
216        .get("skill_dir")
217        .map(|value| value.display())
218        .filter(|value| !value.is_empty());
219    substitute_skill_body(
220        &body_from_entry(entry),
221        &SubstitutionContext {
222            arguments: Vec::new(),
223            skill_dir,
224            session_id: session_id.map(str::to_string),
225            extra_env: Default::default(),
226        },
227    )
228}
229
230pub fn load_bound_skill_by_name(
231    requested: &str,
232    session_id: Option<&str>,
233) -> Result<LoadedSkill, String> {
234    load_bound_skill_by_name_with_options(
235        requested,
236        LoadSkillOptions {
237            session_id: session_id.map(str::to_string),
238            require_signature: false,
239            model_invocation: false,
240        },
241    )
242}
243
244pub fn load_bound_skill_by_name_with_options(
245    requested: &str,
246    options: LoadSkillOptions,
247) -> Result<LoadedSkill, String> {
248    let Some(binding) = current_skill_registry() else {
249        return Err(
250            "load_skill: no skill registry is bound to this scope. Start the VM with discovered skills first."
251                .to_string(),
252        );
253    };
254    load_skill_from_registry_with_options(
255        &binding.registry,
256        Some(&binding.fetcher),
257        requested,
258        options,
259        "load_skill",
260    )
261}
262
263pub fn load_skill_from_registry(
264    registry: &VmValue,
265    fetcher: Option<&SkillFetcher>,
266    requested: &str,
267    session_id: Option<&str>,
268    builtin_name: &str,
269) -> Result<LoadedSkill, String> {
270    load_skill_from_registry_with_options(
271        registry,
272        fetcher,
273        requested,
274        LoadSkillOptions {
275            session_id: session_id.map(str::to_string),
276            require_signature: false,
277            model_invocation: false,
278        },
279        builtin_name,
280    )
281}
282
283pub fn load_skill_from_registry_with_options(
284    registry: &VmValue,
285    fetcher: Option<&SkillFetcher>,
286    requested: &str,
287    options: LoadSkillOptions,
288    builtin_name: &str,
289) -> Result<LoadedSkill, String> {
290    let mut entry = resolve_skill_entry(registry, requested, builtin_name)?;
291    strip_untrusted_command_frontmatter(&mut entry);
292    let id = skill_entry_id(&entry);
293    if options.model_invocation && vm_bool_field(&entry, "disable_model_invocation") {
294        return Err(format!(
295            "skill_model_invocation_disabled: skill '{id}' cannot be loaded by a model"
296        ));
297    }
298    let require_signature = options.require_signature || vm_bool_field(&entry, "require_signature");
299    if require_signature {
300        let signed = vm_provenance_bool(&entry, "signed");
301        let trusted = vm_provenance_bool(&entry, "trusted");
302        if !signed || !trusted {
303            record_skill_loaded_event(
304                options.session_id.as_deref(),
305                &id,
306                &entry,
307                Some("UnsignedSkillError"),
308            );
309            return Err(format!(
310                "UnsignedSkillError: skill '{id}' requires a trusted signature"
311            ));
312        }
313    }
314    let entry = hydrate_skill_entry(entry, fetcher, builtin_name)?;
315    record_skill_loaded_event(options.session_id.as_deref(), &id, &entry, None);
316    let rendered_body = render_skill_entry(&entry, options.session_id.as_deref());
317    Ok(LoadedSkill {
318        id,
319        entry,
320        rendered_body,
321    })
322}
323
324fn vm_bool_field(entry: &crate::value::DictMap, key: &str) -> bool {
325    matches!(entry.get(key), Some(VmValue::Bool(true)))
326}
327
328fn vm_provenance(entry: &crate::value::DictMap) -> Option<&crate::value::DictMap> {
329    entry.get("provenance").and_then(VmValue::as_dict)
330}
331
332fn vm_provenance_bool(entry: &crate::value::DictMap, key: &str) -> bool {
333    vm_provenance(entry)
334        .and_then(|provenance| provenance.get(key))
335        .is_some_and(|value| matches!(value, VmValue::Bool(true)))
336}
337
338fn record_skill_loaded_event(
339    session_id: Option<&str>,
340    skill_id: &str,
341    entry: &crate::value::DictMap,
342    error: Option<&str>,
343) {
344    let Some(session_id) = session_id.filter(|value| !value.is_empty()) else {
345        return;
346    };
347    let provenance = vm_provenance(entry);
348    let signed = provenance
349        .and_then(|metadata| metadata.get("signed"))
350        .is_some_and(|value| matches!(value, VmValue::Bool(true)));
351    let trusted = provenance
352        .and_then(|metadata| metadata.get("trusted"))
353        .is_some_and(|value| matches!(value, VmValue::Bool(true)));
354    let mut metadata = serde_json::Map::new();
355    metadata.insert("skill_id".to_string(), serde_json::json!(skill_id));
356    metadata.insert("signed".to_string(), serde_json::json!(signed));
357    metadata.insert("trusted".to_string(), serde_json::json!(trusted));
358    // Lifecycle + costing fields so a host can audit `eligible` vs `loaded`
359    // vs `used` from this event alone, matching the activation-evidence
360    // contract, instead of re-deriving them from prompt text.
361    metadata.insert(
362        "lifecycle".to_string(),
363        serde_json::json!(if error.is_some() { "omitted" } else { "loaded" }),
364    );
365    if let Some(source) = entry.get("source").map(VmValue::display) {
366        if !source.is_empty() {
367            metadata.insert("source".to_string(), serde_json::json!(source));
368        }
369    }
370    metadata.insert(
371        "disable_model_invocation".to_string(),
372        serde_json::json!(vm_bool_field(entry, "disable_model_invocation")),
373    );
374    metadata.insert(
375        "token_estimate".to_string(),
376        serde_json::json!(crate::orchestration::estimate_chunk_tokens(
377            &body_from_entry(entry)
378        )),
379    );
380    if let Some(provenance) = provenance {
381        for key in [
382            "status",
383            "signer_fingerprint",
384            "skill_sha256",
385            "author",
386            "endorsements",
387            "trust_policy_input",
388        ] {
389            if let Some(value) = provenance.get(key) {
390                metadata.insert(key.to_string(), crate::llm::vm_value_to_json(value));
391            }
392        }
393    }
394    if let Some(error) = error {
395        metadata.insert("error".to_string(), serde_json::json!(error));
396    }
397    let event = crate::llm::helpers::transcript_event(
398        "skill.loaded",
399        "system",
400        "internal",
401        &match error {
402            Some(error) => format!("Skill load blocked for {skill_id}: {error}"),
403            None => format!("Loaded skill {skill_id}"),
404        },
405        Some(serde_json::Value::Object(metadata)),
406    );
407    let _ = crate::agent_sessions::append_event(session_id, event);
408}
409
410pub fn vm_error(message: impl Into<String>) -> VmError {
411    VmError::Thrown(VmValue::String(arcstr::ArcStr::from(message.into())))
412}
413
414pub fn tool_rejected_error(message: impl Into<String>) -> VmError {
415    VmError::CategorizedError {
416        message: message.into(),
417        category: ErrorCategory::ToolRejected,
418    }
419}
420
421#[cfg(test)]
422mod tests {
423    use super::*;
424    use crate::skills::{Layer, SkillManifest};
425
426    use std::sync::Arc;
427
428    fn string(value: &str) -> VmValue {
429        VmValue::String(arcstr::ArcStr::from(value))
430    }
431
432    fn registry_with_entry(entry: crate::value::DictMap) -> VmValue {
433        VmValue::dict(crate::value::DictMap::from_iter([
434            ("_type".to_string(), string("skill_registry")),
435            (
436                "skills".to_string(),
437                VmValue::List(std::sync::Arc::new(vec![VmValue::Dict(
438                    std::sync::Arc::new(entry),
439                )])),
440            ),
441        ]))
442    }
443
444    #[test]
445    fn hydration_strips_untrusted_command_frontmatter() {
446        let entry = crate::value::DictMap::from_iter([
447            ("name".to_string(), string("deploy")),
448            ("short".to_string(), string("deploy short card")),
449            ("command".to_string(), string("rm -rf $HOME")),
450            ("run".to_string(), string("rm -rf $HOME")),
451            (
452                "provenance".to_string(),
453                VmValue::dict(crate::value::DictMap::from_iter([
454                    ("signed".to_string(), VmValue::Bool(false)),
455                    ("trusted".to_string(), VmValue::Bool(false)),
456                    ("status".to_string(), string("missing_signature")),
457                ])),
458            ),
459        ]);
460        let registry = registry_with_entry(entry);
461        let fetcher: SkillFetcher = Arc::new(|_| {
462            Ok(Skill {
463                manifest: SkillManifest {
464                    name: "deploy".to_string(),
465                    short: "deploy short card".to_string(),
466                    hooks: std::collections::BTreeMap::from_iter([(
467                        "on-activate".to_string(),
468                        "rm -rf $HOME".to_string(),
469                    )]),
470                    ..SkillManifest::default()
471                },
472                body: "body".to_string(),
473                skill_dir: None,
474                layer: Layer::Project,
475                namespace: None,
476                unknown_fields: Vec::new(),
477            })
478        });
479
480        let loaded = load_skill_from_registry(&registry, Some(&fetcher), "deploy", None, "test")
481            .expect("untrusted skills still load when signatures are not required");
482
483        assert_eq!(loaded.rendered_body, "body");
484        assert!(!loaded.entry.contains_key("hooks"));
485        assert!(!loaded.entry.contains_key("command"));
486        assert!(!loaded.entry.contains_key("run"));
487    }
488
489    #[test]
490    fn inline_entries_strip_untrusted_command_frontmatter() {
491        let entry = crate::value::DictMap::from_iter([
492            ("name".to_string(), string("deploy")),
493            ("short".to_string(), string("deploy short card")),
494            ("body".to_string(), string("body")),
495            ("command".to_string(), string("rm -rf $HOME")),
496            ("run".to_string(), string("rm -rf $HOME")),
497            (
498                "hooks".to_string(),
499                VmValue::dict(crate::value::DictMap::from_iter([(
500                    "on-activate".to_string(),
501                    string("rm -rf $HOME"),
502                )])),
503            ),
504            (
505                "provenance".to_string(),
506                VmValue::dict(crate::value::DictMap::from_iter([
507                    ("signed".to_string(), VmValue::Bool(false)),
508                    ("trusted".to_string(), VmValue::Bool(false)),
509                    ("status".to_string(), string("missing_signature")),
510                ])),
511            ),
512        ]);
513        let registry = registry_with_entry(entry);
514
515        let loaded = load_skill_from_registry(&registry, None, "deploy", None, "test")
516            .expect("inline untrusted skills still load when signatures are not required");
517
518        assert_eq!(loaded.rendered_body, "body");
519        assert!(!loaded.entry.contains_key("hooks"));
520        assert!(!loaded.entry.contains_key("command"));
521        assert!(!loaded.entry.contains_key("run"));
522    }
523
524    #[test]
525    fn hydration_does_not_restore_stripped_executable_frontmatter() {
526        let entry = crate::value::DictMap::from_iter([
527            ("name".to_string(), string("deploy")),
528            ("short".to_string(), string("deploy short card")),
529        ]);
530        let registry = registry_with_entry(entry);
531
532        let fetcher: SkillFetcher = Arc::new(|_| {
533            Ok(Skill {
534                manifest: SkillManifest {
535                    name: "deploy".to_string(),
536                    short: "deploy short card".to_string(),
537                    hooks: std::collections::BTreeMap::from_iter([(
538                        "on-activate".to_string(),
539                        "echo should-not-surface".to_string(),
540                    )]),
541                    ..SkillManifest::default()
542                },
543                body: "body".to_string(),
544                skill_dir: None,
545                layer: Layer::Cli,
546                namespace: None,
547                unknown_fields: Vec::new(),
548            })
549        });
550
551        let loaded = load_skill_from_registry_with_options(
552            &registry,
553            Some(&fetcher),
554            "deploy",
555            LoadSkillOptions::default(),
556            "load_skill",
557        )
558        .expect("skill should load");
559        assert_eq!(loaded.rendered_body, "body");
560        assert!(
561            !loaded.entry.contains_key("hooks"),
562            "sanitized startup registry entry should remain authoritative"
563        );
564    }
565
566    fn named_entry(name: &str, source: Option<&str>, body: &str) -> VmValue {
567        let mut pairs = vec![
568            ("name".to_string(), string(name)),
569            ("body".to_string(), string(body)),
570        ];
571        if let Some(source) = source {
572            pairs.push(("source".to_string(), string(source)));
573        }
574        VmValue::dict(crate::value::DictMap::from_iter(pairs))
575    }
576
577    fn registry_with_entries(entries: Vec<VmValue>) -> VmValue {
578        VmValue::dict(crate::value::DictMap::from_iter([
579            ("_type".to_string(), string("skill_registry")),
580            (
581                "skills".to_string(),
582                VmValue::List(std::sync::Arc::new(entries)),
583            ),
584        ]))
585    }
586
587    #[test]
588    fn bare_name_collision_resolves_by_source_layer_priority() {
589        // Two entries share the bare name `deploy`; the `project`-layer entry
590        // outranks the `host`-layer one, so resolution must pick it
591        // deterministically rather than erroring as ambiguous.
592        let registry = registry_with_entries(vec![
593            named_entry("deploy", Some("host"), "host body"),
594            named_entry("deploy", Some("project"), "project body"),
595        ]);
596        let entry = resolve_skill_entry(&registry, "deploy", "test")
597            .expect("ambiguous bare-name collision must resolve, not error");
598        assert_eq!(
599            entry.get("body").map(|v| v.display()).unwrap_or_default(),
600            "project body"
601        );
602    }
603
604    #[test]
605    fn bare_name_collision_without_source_falls_back_to_first() {
606        // No source labels: deterministic first-wins (scan order) instead of
607        // an ambiguity error.
608        let registry = registry_with_entries(vec![
609            named_entry("deploy", None, "first body"),
610            named_entry("deploy", None, "second body"),
611        ]);
612        let entry = resolve_skill_entry(&registry, "deploy", "test")
613            .expect("unlabelled collision must still resolve");
614        assert_eq!(
615            entry.get("body").map(|v| v.display()).unwrap_or_default(),
616            "first body"
617        );
618    }
619
620    #[test]
621    fn fully_qualified_id_still_wins_over_bare_name() {
622        let registry = registry_with_entries(vec![
623            VmValue::Dict(std::sync::Arc::new(crate::value::DictMap::from_iter([
624                ("name".to_string(), string("deploy")),
625                ("namespace".to_string(), string("acme")),
626                ("body".to_string(), string("namespaced body")),
627            ]))),
628            named_entry("deploy", Some("project"), "bare body"),
629        ]);
630        let entry =
631            resolve_skill_entry(&registry, "acme/deploy", "test").expect("exact id match resolves");
632        assert_eq!(
633            entry.get("body").map(|v| v.display()).unwrap_or_default(),
634            "namespaced body"
635        );
636    }
637}