1pub mod declaration;
14
15use std::sync::Arc;
16
17use wasmtime::{FuncType, HeapType, Linker, RefType, StructType, Val, ValType};
18
19use crate::runtime::StoreData;
20use crate::runtime::call_log::{Payload, Side, record_payload};
21use crate::runtime::decision::CallTicket;
22use crate::runtime::fuel;
23use crate::runtime::host::{
24 abi_arg, abi_result, range_error, read_string_arg, register_host_fn_async,
25 write_submilli_string_struct,
26};
27use crate::runtime::intrinsic_types::build_intrinsic_types;
28use crate::runtime::skills::{Skill, SkillError, SkillInfo, SkillProvider};
29use crate::stdlib::abi::{
30 self, backing_struct, install_field_getters, nullable_object_field, string_field,
31};
32use crate::stdlib::shared::{
33 check_security_call, permitted_candidates, preflight_listing, sanitize_description, truncated,
34};
35
36pub use declaration::package_declaration;
37
38pub const MODULE_NAME: &str = crate::runtime::skills::SKILLS_MODULE_NAME;
39
40pub const CAPABILITY: &str = "skill.load";
42
43const S_NAME: usize = 1;
45const S_DESCRIPTION: usize = 2;
46const S_CONTENT: usize = 3;
47
48pub fn install(linker: &mut Linker<StoreData>) -> wasmtime::Result<()> {
49 let engine = linker.engine().clone();
50 let intr = build_intrinsic_types(&engine)?;
51 let string = ValType::Ref(RefType::new(
52 false,
53 HeapType::ConcreteStruct(intr.string.clone()),
54 ));
55 let nullable_object = ValType::Ref(RefType::new(
56 true,
57 HeapType::ConcreteStruct(intr.object.clone()),
58 ));
59 let object = ValType::Ref(RefType::new(
60 false,
61 HeapType::ConcreteStruct(intr.object.clone()),
62 ));
63 let array = ValType::Ref(RefType::new(
64 false,
65 HeapType::ConcreteStruct(intr.array.clone()),
66 ));
67
68 register_host_fn_async(
69 linker,
70 MODULE_NAME,
71 crate::mangle::package_symbol(MODULE_NAME, "list"),
72 FuncType::new(&engine, [], [array]),
73 false,
74 |caller, _params, results| {
75 Box::pin(async move {
76 *abi_result(results, 0)? = list(caller).await?;
77 Ok(())
78 })
79 },
80 )?;
81
82 register_host_fn_async(
83 linker,
84 MODULE_NAME,
85 crate::mangle::package_symbol(MODULE_NAME, "load"),
86 FuncType::new(&engine, [string.clone()], [nullable_object.clone()]),
88 false,
89 |caller, params, results| {
90 Box::pin(async move {
91 let name =
92 read_string_arg(&mut *caller, abi_arg(params, 0)?, "skills.load (name)")?;
93 *abi_result(results, 0)? = load(caller, name).await?;
94 Ok(())
95 })
96 },
97 )?;
98
99 register_host_fn_async(
100 linker,
101 MODULE_NAME,
102 crate::mangle::package_symbol(MODULE_NAME, "readFile"),
103 FuncType::new(&engine, [string.clone(), string.clone()], [string.clone()]),
104 false,
105 |caller, params, results| {
106 Box::pin(async move {
107 let name =
108 read_string_arg(&mut *caller, abi_arg(params, 0)?, "skills.readFile (name)")?;
109 let path =
110 read_string_arg(&mut *caller, abi_arg(params, 1)?, "skills.readFile (path)")?;
111 *abi_result(results, 0)? = read_file(caller, name, path).await?;
112 Ok(())
113 })
114 },
115 )?;
116
117 let info_rows = [
118 ("name", S_NAME, string.clone()),
119 ("description", S_DESCRIPTION, nullable_object),
120 ];
121 install_field_getters(
122 linker,
123 MODULE_NAME,
124 "SkillInfo",
125 &engine,
126 &object,
127 &info_rows,
128 )?;
129 let mut skill_rows = info_rows.to_vec();
130 skill_rows.push(("content", S_CONTENT, string));
131 install_field_getters(linker, MODULE_NAME, "Skill", &engine, &object, &skill_rows)
132}
133
134fn gate(
136 caller: &mut wasmtime::Caller<'_, StoreData>,
137 name: &str,
138) -> wasmtime::Result<Option<CallTicket>> {
139 check_security_call(caller, CAPABILITY, serde_json::json!({ "name": name }))
140}
141
142async fn list(caller: &mut wasmtime::Caller<'_, StoreData>) -> wasmtime::Result<Val> {
146 preflight_listing(caller, CAPABILITY, &serde_json::json!({ "name": "" }))?;
147 let provider = provider(caller).map_err(|error| throw("list", &error))?;
148 let candidates = provider.list().await.map_err(|e| throw("list", &e))?;
149 let loadable = candidates
151 .into_iter()
152 .filter(|skill| is_skill_name(&skill.name))
153 .collect();
154 let visible = permitted_candidates(
155 caller,
156 loadable,
157 |skill| skill.name.as_str(),
158 |caller, name| gate(caller, name).map(|_| ()),
159 )?;
160 let mut built = Vec::with_capacity(visible.len());
161 for skill in visible {
162 built.push(build_skill_info(caller, skill)?);
163 }
164 abi::new_array(caller, &built)
165}
166
167async fn load(caller: &mut wasmtime::Caller<'_, StoreData>, name: String) -> wasmtime::Result<Val> {
171 let ticket = gate(caller, &name)?;
172 if !is_skill_name(&name) {
173 return Err(fail(
174 caller,
175 ticket,
176 "load",
177 &SkillError::InvalidName { name },
178 ));
179 }
180 let provider = provider(caller).map_err(|error| fail(caller, ticket, "load", &error))?;
181 record_payload(&*caller, ticket, Side::Request, || {
182 Payload::meta(serde_json::json!({ "op": "load", "name": name }))
183 });
184 match provider.load(&name).await {
185 Ok(skill) if skill.name != name => {
188 fuel::settle(&mut *caller, fuel::IO, skill.content.len() as u64)?;
190 Err(fail(
191 caller,
192 ticket,
193 "load",
194 &SkillError::WrongSkill { name },
195 ))
196 }
197 Ok(skill) => {
198 record_text(caller, ticket, &skill.content);
199 fuel::settle(&mut *caller, fuel::IO, skill.content.len() as u64)?;
200 fuel::settle_result(caller, |caller| build_skill(caller, skill))
201 }
202 Err(error) => Err(fail(caller, ticket, "load", &error)),
203 }
204}
205
206async fn read_file(
209 caller: &mut wasmtime::Caller<'_, StoreData>,
210 name: String,
211 path: String,
212) -> wasmtime::Result<Val> {
213 let ticket = gate(caller, &name)?;
214 if !is_skill_name(&name) {
215 return Err(fail(
216 caller,
217 ticket,
218 "readFile",
219 &SkillError::InvalidName { name },
220 ));
221 }
222 if !is_skill_path(&path) {
223 return Err(fail(
224 caller,
225 ticket,
226 "readFile",
227 &SkillError::InvalidPath { path },
228 ));
229 }
230 let provider = provider(caller).map_err(|error| fail(caller, ticket, "readFile", &error))?;
231 record_payload(&*caller, ticket, Side::Request, || {
232 Payload::meta(serde_json::json!({ "op": "readFile", "name": name, "path": path }))
233 });
234 match provider.read_file(&name, &path).await {
235 Ok(text) => {
236 record_text(caller, ticket, &text);
237 fuel::settle(&mut *caller, fuel::IO, text.len() as u64)?;
238 fuel::settle_result(caller, |caller| {
239 let text = write_submilli_string_struct(caller, &text)?;
240 Ok(Val::AnyRef(Some(text.to_anyref())))
241 })
242 }
243 Err(error) => Err(fail(caller, ticket, "readFile", &error)),
244 }
245}
246
247const MAX_SKILL_ARG_BYTES: usize = 4096;
250
251fn is_skill_name(name: &str) -> bool {
255 name.len() <= MAX_SKILL_ARG_BYTES
256 && !name.contains(['/', '\\', '\0'])
257 && !matches!(name, "" | "." | "..")
258}
259
260fn is_skill_path(path: &str) -> bool {
264 path.len() <= MAX_SKILL_ARG_BYTES
265 && !path.contains(['\\', ':', '\0'])
266 && path
267 .split('/')
268 .all(|segment| !matches!(segment, "" | "." | ".."))
269}
270
271fn record_text(caller: &wasmtime::Caller<'_, StoreData>, ticket: Option<CallTicket>, text: &str) {
272 record_payload(caller, ticket, Side::Response, || {
273 Payload::meta(serde_json::Value::Null)
274 .with_owned_body(text.as_bytes().to_vec())
275 .with_size(text.len() as u64)
276 });
277}
278
279fn fail(
281 caller: &wasmtime::Caller<'_, StoreData>,
282 ticket: Option<CallTicket>,
283 op: &str,
284 error: &SkillError,
285) -> wasmtime::Error {
286 record_payload(caller, ticket, Side::Response, || {
287 Payload::meta(serde_json::json!({ "call_error": call_error_record(error) }))
288 });
289 throw(op, error)
290}
291
292fn provider(
296 caller: &wasmtime::Caller<'_, StoreData>,
297) -> Result<Arc<dyn SkillProvider>, SkillError> {
298 caller
299 .data()
300 .skill_provider
301 .clone()
302 .ok_or(SkillError::NotConfigured)
303}
304
305fn throw(op: &str, error: &SkillError) -> wasmtime::Error {
308 let message = format!("skills.{op}: {error}");
309 if matches!(
310 error,
311 SkillError::InvalidName { .. } | SkillError::InvalidPath { .. }
312 ) {
313 range_error(message)
314 } else {
315 wasmtime::Error::msg(message)
316 }
317}
318
319fn call_error_record(error: &SkillError) -> serde_json::Value {
321 match error {
322 SkillError::NotConfigured => serde_json::json!({ "kind": "not-configured" }),
323 SkillError::NotFound { name } => serde_json::json!({ "kind": "not-found", "name": name }),
324 SkillError::FileNotFound { name, path } => {
325 serde_json::json!({ "kind": "file-not-found", "name": name, "path": path })
326 }
327 SkillError::InvalidName { name } => {
328 serde_json::json!({ "kind": "invalid-name", "name": truncated(name) })
329 }
330 SkillError::WrongSkill { name } => {
331 serde_json::json!({ "kind": "wrong-skill", "name": name })
332 }
333 SkillError::InvalidPath { path } => {
334 serde_json::json!({ "kind": "invalid-path", "path": truncated(path) })
335 }
336 SkillError::Failed { message } => {
337 serde_json::json!({ "kind": "failed", "message": message })
338 }
339 }
340}
341
342fn info_backing_struct(engine: &wasmtime::Engine) -> wasmtime::Result<StructType> {
343 let intr = build_intrinsic_types(engine)?;
344 backing_struct(
345 engine,
346 &intr,
347 vec![string_field(&intr), nullable_object_field(&intr)],
348 )
349}
350
351fn skill_backing_struct(engine: &wasmtime::Engine) -> wasmtime::Result<StructType> {
352 let intr = build_intrinsic_types(engine)?;
353 backing_struct(
354 engine,
355 &intr,
356 vec![
357 string_field(&intr),
358 nullable_object_field(&intr),
359 string_field(&intr),
360 ],
361 )
362}
363
364fn build_skill_info(
365 caller: &mut wasmtime::Caller<'_, StoreData>,
366 skill: SkillInfo,
367) -> wasmtime::Result<Val> {
368 let name = write_submilli_string_struct(caller, &skill.name)?;
369 let description = listing_description(caller, skill.description.as_deref())?;
370 let ty = info_backing_struct(caller.engine())?;
371 abi::new_backing(
372 caller,
373 ty,
374 &[Val::AnyRef(Some(name.to_anyref())), description],
375 )
376}
377
378fn build_skill(
379 caller: &mut wasmtime::Caller<'_, StoreData>,
380 skill: Skill,
381) -> wasmtime::Result<Val> {
382 let name = write_submilli_string_struct(caller, &skill.name)?;
383 let description = optional_text(caller, skill.description.as_deref())?;
384 let content = write_submilli_string_struct(caller, &skill.content)?;
385 let ty = skill_backing_struct(caller.engine())?;
386 abi::new_backing(
387 caller,
388 ty,
389 &[
390 Val::AnyRef(Some(name.to_anyref())),
391 description,
392 Val::AnyRef(Some(content.to_anyref())),
393 ],
394 )
395}
396
397fn listing_description(
400 caller: &mut wasmtime::Caller<'_, StoreData>,
401 text: Option<&str>,
402) -> wasmtime::Result<Val> {
403 optional_text(caller, text.and_then(sanitize_description).as_deref())
404}
405
406fn optional_text(
408 caller: &mut wasmtime::Caller<'_, StoreData>,
409 text: Option<&str>,
410) -> wasmtime::Result<Val> {
411 match text {
412 Some(text) => Ok(Val::AnyRef(Some(
413 write_submilli_string_struct(caller, text)?.to_anyref(),
414 ))),
415 None => crate::runtime::prelude::undefined::value(caller),
416 }
417}
418
419#[cfg(test)]
420mod path_tests {
421 use super::{MAX_SKILL_ARG_BYTES, is_skill_name, is_skill_path};
422 use crate::stdlib::shared::truncated;
423
424 #[test]
425 fn only_relative_paths_inside_the_skill_are_accepted() {
426 let longest = "a".repeat(MAX_SKILL_ARG_BYTES);
427 for ok in [
428 "SKILL.md",
429 "templates/report.md",
430 "a/b/c.txt",
431 ".hidden",
432 "notes/résumé ✓.md",
433 "%2e%2e/x",
434 longest.as_str(),
435 ] {
436 assert!(is_skill_path(ok), "{ok}");
437 }
438 let too_long = "a".repeat(MAX_SKILL_ARG_BYTES + 1);
439 let straddling = format!("{}é", "a".repeat(MAX_SKILL_ARG_BYTES - 1));
441 for bad in [
442 "",
443 "/etc/passwd",
444 "a//b",
445 "./a",
446 "a/.",
447 "../x",
448 "a/../b",
449 "a/",
450 "a\\b",
451 "a\0b",
452 "a:b",
453 "C:/x",
454 "C:x",
455 "SKILL.md:stream",
456 too_long.as_str(),
457 straddling.as_str(),
458 ] {
459 assert!(!is_skill_path(bad), "{bad:?}");
460 }
461 }
462
463 #[test]
464 fn a_skill_name_is_one_segment_and_may_be_namespaced() {
465 let longest = "n".repeat(MAX_SKILL_ARG_BYTES);
466 for ok in [
467 "code-review",
468 "code.review",
469 "plugin:skill",
470 "C:",
471 longest.as_str(),
472 ] {
473 assert!(is_skill_name(ok), "{ok}");
474 }
475 let too_long = "n".repeat(MAX_SKILL_ARG_BYTES + 1);
476 for bad in [
477 "",
478 ".",
479 "..",
480 "../other",
481 "a/b",
482 "a\\b",
483 "a\0b",
484 too_long.as_str(),
485 ] {
486 assert!(!is_skill_name(bad), "{bad:?}");
487 }
488 }
489
490 #[test]
491 fn an_echoed_value_is_cut_on_a_character_boundary() {
492 assert_eq!(truncated("short"), "short");
493 let exactly = "a".repeat(200);
494 assert_eq!(truncated(&exactly), exactly.as_str());
495 let cut = truncated(&"€".repeat(100)).into_owned();
497 assert_eq!(cut, format!("{}…", "€".repeat(66)));
498 }
499}