Skip to main content

submilli_engine/stdlib/skills/
mod.rs

1//! `submilli:skills` — read the skills a harness offers from inside a Submilli
2//! program.
3//!
4//! An optional package: it exists only for an embedder that enables
5//! [`OptionalPackage::Skills`](crate::stdlib::OptionalPackage::Skills). The
6//! harness serves skills through [`StoreData::skill_provider`]; a runtime with
7//! none refuses every op with a catchable error.
8//!
9//! **One capability.** `list()`, `load` and `readFile` are all `skill.load`,
10//! filtered by `name`. `list()` gates each candidate with the same filter, so a
11//! listing never offers a skill the caller would be denied.
12
13pub 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
40/// The capability gating every op in this package.
41pub const CAPABILITY: &str = "skill.load";
42
43// Backing field indices (0 is the vtable). `Skill` extends `SkillInfo`'s layout.
44const 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        /* deterministic = */ 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        // A `Skill` crosses as the universal `(ref null $Object)` lowering.
87        FuncType::new(&engine, [string.clone()], [nullable_object.clone()]),
88        /* deterministic = */ 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        /* deterministic = */ 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
134/// The capability check, run before anything reaches the harness.
135fn 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
142/// `list()`: every skill the harness offers that the caller may load. A denied
143/// candidate is left out, never reported. Whether a provider is wired is not
144/// hidden here: a listing has no candidate to gate before asking it.
145async 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    // A name `load` would refuse is no use to the program, so it is not offered.
150    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
167/// `load(name)`: gate, refuse what cannot be sent, record the request, then ask
168/// the provider. A refusal before the provider is asked is recorded as the
169/// call's error, with no request.
170async 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        // The policy decided on `name`; a provider that answered with another
186        // skill would hand the program one the policy never saw.
187        Ok(skill) if skill.name != name => {
188            // The read happened, so it is paid for; its content is not kept.
189            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
206/// `readFile(name, path)`: in the order `load` follows, with the path checked
207/// beside the name.
208async 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
247/// The longest skill name, and the longest path, the engine passes to a
248/// provider.
249const MAX_SKILL_ARG_BYTES: usize = 4096;
250
251/// A skill name: one segment that is not `.` or `..`, with no `/`, `\\` or NUL.
252/// `:` is allowed, for namespaced names such as `plugin:skill`; a provider must
253/// not read a name as a path.
254fn is_skill_name(name: &str) -> bool {
255    name.len() <= MAX_SKILL_ARG_BYTES
256        && !name.contains(['/', '\\', '\0'])
257        && !matches!(name, "" | "." | "..")
258}
259
260/// A path inside a skill: relative, `/`-separated, with no empty, `.` or `..`
261/// segment, and none of the characters a provider could read as structure — a
262/// backslash or `:` (Windows separators, drives and streams) or NUL.
263fn 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
279/// Record `error` as the call's response and turn it into the guest's error.
280fn 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
292/// Clone the provider out of the store before any `await`. `load` and
293/// `readFile` look it up after the capability check, so a denied caller cannot
294/// learn whether a harness is wired.
295fn 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
305/// A name or path outside the skills is an argument the program got wrong; every
306/// other failure keeps the base error type.
307fn 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
319/// A failure as the call log keeps it: a stable kind and what it was about.
320fn 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
397/// A listing shows a description as one bounded line, because it steers which
398/// skill a program picks.
399fn 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
406/// `text` as a string, or `undefined` when absent.
407fn 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        // 4095 ASCII bytes and a two-byte character: 4097 bytes, 4096 chars.
440        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        // Three-byte characters: byte 200 falls inside one, so the cut is at 198.
496        let cut = truncated(&"€".repeat(100)).into_owned();
497        assert_eq!(cut, format!("{}…", "€".repeat(66)));
498    }
499}