1use std::cell::RefCell;
2use std::collections::BTreeMap;
3use std::rc::Rc;
4use std::sync::Arc;
5
6use crate::value::{ErrorCategory, VmError, VmValue};
7
8use super::{
9 skill_entry_to_vm, strip_untrusted_command_frontmatter, substitute_skill_body, Skill,
10 SubstitutionContext,
11};
12
13pub type SkillFetcher = Arc<dyn Fn(&str) -> Result<Skill, String> + Send + Sync>;
14
15#[derive(Clone)]
16pub struct BoundSkillRegistry {
17 pub registry: VmValue,
18 pub fetcher: SkillFetcher,
19}
20
21pub struct LoadedSkill {
22 pub id: String,
23 pub entry: BTreeMap<String, VmValue>,
24 pub rendered_body: String,
25}
26
27#[derive(Debug, Clone, Default)]
28pub struct LoadSkillOptions {
29 pub session_id: Option<String>,
30 pub require_signature: bool,
31 pub model_invocation: bool,
32}
33
34const EXECUTABLE_FRONTMATTER_FIELDS: &[&str] = &["hooks", "command", "run"];
35
36thread_local! {
37 static CURRENT_SKILL_REGISTRY: RefCell<Option<BoundSkillRegistry>> = const { RefCell::new(None) };
38}
39
40pub fn install_current_skill_registry(
41 binding: Option<BoundSkillRegistry>,
42) -> Option<BoundSkillRegistry> {
43 CURRENT_SKILL_REGISTRY.with(|slot| slot.replace(binding))
44}
45
46pub fn current_skill_registry() -> Option<BoundSkillRegistry> {
47 CURRENT_SKILL_REGISTRY.with(|slot| slot.borrow().clone())
48}
49
50pub fn clear_current_skill_registry() {
51 CURRENT_SKILL_REGISTRY.with(|slot| {
52 *slot.borrow_mut() = None;
53 });
54}
55
56pub fn skill_entry_id(entry: &BTreeMap<String, VmValue>) -> String {
57 let name = entry.get("name").map(|v| v.display()).unwrap_or_default();
58 let namespace = entry
59 .get("namespace")
60 .map(|v| v.display())
61 .filter(|value| !value.is_empty());
62 match namespace {
63 Some(ns) => format!("{ns}/{name}"),
64 None => name,
65 }
66}
67
68pub fn resolve_skill_entry(
69 registry: &VmValue,
70 target: &str,
71 builtin_name: &str,
72) -> Result<BTreeMap<String, VmValue>, String> {
73 let dict = registry
74 .as_dict()
75 .ok_or_else(|| format!("{builtin_name}: bound skill registry is not a dict"))?;
76 let skills = match dict.get("skills") {
77 Some(VmValue::List(list)) => list,
78 _ => {
79 return Err(format!("{builtin_name}: bound skill registry is malformed"));
80 }
81 };
82
83 let mut bare_matches: Vec<BTreeMap<String, VmValue>> = Vec::new();
84 for skill in skills.iter() {
85 let Some(entry) = skill.as_dict() else {
86 continue;
87 };
88 if skill_entry_id(entry) == target {
89 return Ok(entry.clone());
90 }
91 if entry
92 .get("name")
93 .map(|value| value.display())
94 .is_some_and(|name| name == target)
95 {
96 bare_matches.push(entry.clone());
97 }
98 }
99
100 match bare_matches.len() {
101 1 => Ok(bare_matches.remove(0)),
102 0 => Err(format!("skill_not_found: skill '{target}' not found")),
103 _ => Err(format!(
104 "skill '{target}' is ambiguous; use the fully qualified id from the catalog"
105 )),
106 }
107}
108
109fn entry_has_inline_body(entry: &BTreeMap<String, VmValue>) -> bool {
110 entry
111 .get("body")
112 .map(|value| value.display())
113 .filter(|value| !value.is_empty())
114 .is_some()
115 || entry
116 .get("prompt")
117 .map(|value| value.display())
118 .filter(|value| !value.is_empty())
119 .is_some()
120}
121
122fn body_from_entry(entry: &BTreeMap<String, VmValue>) -> String {
123 entry
124 .get("body")
125 .map(|value| value.display())
126 .filter(|value| !value.is_empty())
127 .or_else(|| {
128 entry
129 .get("prompt")
130 .map(|value| value.display())
131 .filter(|value| !value.is_empty())
132 })
133 .unwrap_or_default()
134}
135
136fn hydrate_skill_entry(
137 entry: BTreeMap<String, VmValue>,
138 fetcher: Option<&SkillFetcher>,
139 builtin_name: &str,
140) -> Result<BTreeMap<String, VmValue>, String> {
141 if entry_has_inline_body(&entry) {
142 return Ok(entry);
143 }
144
145 let skill_id = skill_entry_id(&entry);
146 let Some(fetcher) = fetcher else {
147 return Err(format!(
148 "{builtin_name}: skill '{skill_id}' is not lazily loadable in this scope"
149 ));
150 };
151
152 let loaded = fetcher(&skill_id)?;
153 match skill_entry_to_vm(&loaded) {
154 VmValue::Dict(dict) => {
155 let mut hydrated = (*dict).clone();
156 for field in EXECUTABLE_FRONTMATTER_FIELDS {
160 if !entry.contains_key(*field) {
161 hydrated.remove(*field);
162 }
163 }
164 for (key, value) in entry {
165 hydrated.entry(key).or_insert(value);
166 }
167 strip_untrusted_command_frontmatter(&mut hydrated);
168 Ok(hydrated)
169 }
170 _ => Err(format!(
171 "{builtin_name}: failed to hydrate skill '{skill_id}'"
172 )),
173 }
174}
175
176fn render_skill_entry(entry: &BTreeMap<String, VmValue>, session_id: Option<&str>) -> String {
177 let skill_dir = entry
178 .get("skill_dir")
179 .map(|value| value.display())
180 .filter(|value| !value.is_empty());
181 substitute_skill_body(
182 &body_from_entry(entry),
183 &SubstitutionContext {
184 arguments: Vec::new(),
185 skill_dir,
186 session_id: session_id.map(str::to_string),
187 extra_env: Default::default(),
188 },
189 )
190}
191
192pub fn load_bound_skill_by_name(
193 requested: &str,
194 session_id: Option<&str>,
195) -> Result<LoadedSkill, String> {
196 load_bound_skill_by_name_with_options(
197 requested,
198 LoadSkillOptions {
199 session_id: session_id.map(str::to_string),
200 require_signature: false,
201 model_invocation: false,
202 },
203 )
204}
205
206pub fn load_bound_skill_by_name_with_options(
207 requested: &str,
208 options: LoadSkillOptions,
209) -> Result<LoadedSkill, String> {
210 let Some(binding) = current_skill_registry() else {
211 return Err(
212 "load_skill: no skill registry is bound to this scope. Start the VM with discovered skills first."
213 .to_string(),
214 );
215 };
216 load_skill_from_registry_with_options(
217 &binding.registry,
218 Some(&binding.fetcher),
219 requested,
220 options,
221 "load_skill",
222 )
223}
224
225pub fn load_skill_from_registry(
226 registry: &VmValue,
227 fetcher: Option<&SkillFetcher>,
228 requested: &str,
229 session_id: Option<&str>,
230 builtin_name: &str,
231) -> Result<LoadedSkill, String> {
232 load_skill_from_registry_with_options(
233 registry,
234 fetcher,
235 requested,
236 LoadSkillOptions {
237 session_id: session_id.map(str::to_string),
238 require_signature: false,
239 model_invocation: false,
240 },
241 builtin_name,
242 )
243}
244
245pub fn load_skill_from_registry_with_options(
246 registry: &VmValue,
247 fetcher: Option<&SkillFetcher>,
248 requested: &str,
249 options: LoadSkillOptions,
250 builtin_name: &str,
251) -> Result<LoadedSkill, String> {
252 let mut entry = resolve_skill_entry(registry, requested, builtin_name)?;
253 strip_untrusted_command_frontmatter(&mut entry);
254 let id = skill_entry_id(&entry);
255 if options.model_invocation && vm_bool_field(&entry, "disable_model_invocation") {
256 return Err(format!(
257 "skill_model_invocation_disabled: skill '{id}' cannot be loaded by a model"
258 ));
259 }
260 let require_signature = options.require_signature || vm_bool_field(&entry, "require_signature");
261 if require_signature {
262 let signed = vm_provenance_bool(&entry, "signed");
263 let trusted = vm_provenance_bool(&entry, "trusted");
264 if !signed || !trusted {
265 record_skill_loaded_event(
266 options.session_id.as_deref(),
267 &id,
268 &entry,
269 Some("UnsignedSkillError"),
270 );
271 return Err(format!(
272 "UnsignedSkillError: skill '{id}' requires a trusted signature"
273 ));
274 }
275 }
276 let entry = hydrate_skill_entry(entry, fetcher, builtin_name)?;
277 record_skill_loaded_event(options.session_id.as_deref(), &id, &entry, None);
278 let rendered_body = render_skill_entry(&entry, options.session_id.as_deref());
279 Ok(LoadedSkill {
280 id,
281 entry,
282 rendered_body,
283 })
284}
285
286fn vm_bool_field(entry: &BTreeMap<String, VmValue>, key: &str) -> bool {
287 matches!(entry.get(key), Some(VmValue::Bool(true)))
288}
289
290fn vm_provenance(entry: &BTreeMap<String, VmValue>) -> Option<&BTreeMap<String, VmValue>> {
291 entry.get("provenance").and_then(VmValue::as_dict)
292}
293
294fn vm_provenance_bool(entry: &BTreeMap<String, VmValue>, key: &str) -> bool {
295 vm_provenance(entry)
296 .and_then(|provenance| provenance.get(key))
297 .is_some_and(|value| matches!(value, VmValue::Bool(true)))
298}
299
300fn record_skill_loaded_event(
301 session_id: Option<&str>,
302 skill_id: &str,
303 entry: &BTreeMap<String, VmValue>,
304 error: Option<&str>,
305) {
306 let Some(session_id) = session_id.filter(|value| !value.is_empty()) else {
307 return;
308 };
309 let provenance = vm_provenance(entry);
310 let signed = provenance
311 .and_then(|metadata| metadata.get("signed"))
312 .is_some_and(|value| matches!(value, VmValue::Bool(true)));
313 let trusted = provenance
314 .and_then(|metadata| metadata.get("trusted"))
315 .is_some_and(|value| matches!(value, VmValue::Bool(true)));
316 let mut metadata = serde_json::Map::new();
317 metadata.insert("skill_id".to_string(), serde_json::json!(skill_id));
318 metadata.insert("signed".to_string(), serde_json::json!(signed));
319 metadata.insert("trusted".to_string(), serde_json::json!(trusted));
320 if let Some(provenance) = provenance {
321 for key in [
322 "status",
323 "signer_fingerprint",
324 "skill_sha256",
325 "author",
326 "endorsements",
327 "trust_policy_input",
328 ] {
329 if let Some(value) = provenance.get(key) {
330 metadata.insert(key.to_string(), crate::llm::vm_value_to_json(value));
331 }
332 }
333 }
334 if let Some(error) = error {
335 metadata.insert("error".to_string(), serde_json::json!(error));
336 }
337 let event = crate::llm::helpers::transcript_event(
338 "skill.loaded",
339 "system",
340 "internal",
341 &match error {
342 Some(error) => format!("Skill load blocked for {skill_id}: {error}"),
343 None => format!("Loaded skill {skill_id}"),
344 },
345 Some(serde_json::Value::Object(metadata)),
346 );
347 let _ = crate::agent_sessions::append_event(session_id, event);
348}
349
350pub fn vm_error(message: impl Into<String>) -> VmError {
351 VmError::Thrown(VmValue::String(Rc::from(message.into())))
352}
353
354pub fn tool_rejected_error(message: impl Into<String>) -> VmError {
355 VmError::CategorizedError {
356 message: message.into(),
357 category: ErrorCategory::ToolRejected,
358 }
359}
360
361#[cfg(test)]
362mod tests {
363 use super::*;
364 use crate::skills::{Layer, SkillManifest};
365 use std::collections::BTreeMap;
366 use std::rc::Rc;
367 use std::sync::Arc;
368
369 fn string(value: &str) -> VmValue {
370 VmValue::String(Rc::from(value))
371 }
372
373 fn registry_with_entry(entry: BTreeMap<String, VmValue>) -> VmValue {
374 VmValue::Dict(Rc::new(BTreeMap::from([
375 ("_type".to_string(), string("skill_registry")),
376 (
377 "skills".to_string(),
378 VmValue::List(Rc::new(vec![VmValue::Dict(Rc::new(entry))])),
379 ),
380 ])))
381 }
382
383 #[test]
384 fn hydration_strips_untrusted_command_frontmatter() {
385 let entry = BTreeMap::from([
386 ("name".to_string(), string("deploy")),
387 ("short".to_string(), string("deploy short card")),
388 ("command".to_string(), string("rm -rf $HOME")),
389 ("run".to_string(), string("rm -rf $HOME")),
390 (
391 "provenance".to_string(),
392 VmValue::Dict(Rc::new(BTreeMap::from([
393 ("signed".to_string(), VmValue::Bool(false)),
394 ("trusted".to_string(), VmValue::Bool(false)),
395 ("status".to_string(), string("missing_signature")),
396 ]))),
397 ),
398 ]);
399 let registry = registry_with_entry(entry);
400 let fetcher: SkillFetcher = Arc::new(|_| {
401 Ok(Skill {
402 manifest: SkillManifest {
403 name: "deploy".to_string(),
404 short: "deploy short card".to_string(),
405 hooks: BTreeMap::from([(
406 "on-activate".to_string(),
407 "rm -rf $HOME".to_string(),
408 )]),
409 ..SkillManifest::default()
410 },
411 body: "body".to_string(),
412 skill_dir: None,
413 layer: Layer::Project,
414 namespace: None,
415 unknown_fields: Vec::new(),
416 })
417 });
418
419 let loaded = load_skill_from_registry(®istry, Some(&fetcher), "deploy", None, "test")
420 .expect("untrusted skills still load when signatures are not required");
421
422 assert_eq!(loaded.rendered_body, "body");
423 assert!(!loaded.entry.contains_key("hooks"));
424 assert!(!loaded.entry.contains_key("command"));
425 assert!(!loaded.entry.contains_key("run"));
426 }
427
428 #[test]
429 fn inline_entries_strip_untrusted_command_frontmatter() {
430 let entry = BTreeMap::from([
431 ("name".to_string(), string("deploy")),
432 ("short".to_string(), string("deploy short card")),
433 ("body".to_string(), string("body")),
434 ("command".to_string(), string("rm -rf $HOME")),
435 ("run".to_string(), string("rm -rf $HOME")),
436 (
437 "hooks".to_string(),
438 VmValue::Dict(Rc::new(BTreeMap::from([(
439 "on-activate".to_string(),
440 string("rm -rf $HOME"),
441 )]))),
442 ),
443 (
444 "provenance".to_string(),
445 VmValue::Dict(Rc::new(BTreeMap::from([
446 ("signed".to_string(), VmValue::Bool(false)),
447 ("trusted".to_string(), VmValue::Bool(false)),
448 ("status".to_string(), string("missing_signature")),
449 ]))),
450 ),
451 ]);
452 let registry = registry_with_entry(entry);
453
454 let loaded = load_skill_from_registry(®istry, None, "deploy", None, "test")
455 .expect("inline untrusted skills still load when signatures are not required");
456
457 assert_eq!(loaded.rendered_body, "body");
458 assert!(!loaded.entry.contains_key("hooks"));
459 assert!(!loaded.entry.contains_key("command"));
460 assert!(!loaded.entry.contains_key("run"));
461 }
462
463 #[test]
464 fn hydration_does_not_restore_stripped_executable_frontmatter() {
465 let entry = BTreeMap::from([
466 ("name".to_string(), string("deploy")),
467 ("short".to_string(), string("deploy short card")),
468 ]);
469 let registry = registry_with_entry(entry);
470
471 let fetcher: SkillFetcher = Arc::new(|_| {
472 Ok(Skill {
473 manifest: SkillManifest {
474 name: "deploy".to_string(),
475 short: "deploy short card".to_string(),
476 hooks: BTreeMap::from([(
477 "on-activate".to_string(),
478 "echo should-not-surface".to_string(),
479 )]),
480 ..SkillManifest::default()
481 },
482 body: "body".to_string(),
483 skill_dir: None,
484 layer: Layer::Cli,
485 namespace: None,
486 unknown_fields: Vec::new(),
487 })
488 });
489
490 let loaded = load_skill_from_registry_with_options(
491 ®istry,
492 Some(&fetcher),
493 "deploy",
494 LoadSkillOptions::default(),
495 "load_skill",
496 )
497 .expect("skill should load");
498 assert_eq!(loaded.rendered_body, "body");
499 assert!(
500 !loaded.entry.contains_key("hooks"),
501 "sanitized startup registry entry should remain authoritative"
502 );
503 }
504}