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