Skip to main content

submilli_engine/stdlib/
security.rs

1//! `submilli:security` — semantic capability check.
2//!
3//! One async Rust host function registered directly under the package name.
4//! The `context` value is serialized by re-entering its `toJson` vtable slot
5//! (the dispatch may run guest code for user classes), then handed to the
6//! embedder's policy engine.
7
8use crate::runtime::host::abi_arg;
9use wasmtime::{FuncType, HeapType, Linker, RefType, Val, ValType};
10
11use crate::runtime::StoreData;
12use crate::runtime::decision::{CallSite, EntryPath};
13use crate::runtime::fuel;
14use crate::runtime::host::{
15    permission_denied, permission_denied_invariant, read_string_arg, register_host_fn_async,
16};
17use crate::runtime::intrinsic_types::build_intrinsic_types;
18use crate::runtime::prelude::vtable::dispatch_vtable_slot;
19use crate::{MangledName, PackageDeclaration, Param, Span, Type, ValueKind, ValueSymbol};
20
21pub const MODULE_NAME: &str = "submilli:security";
22
23/// Whether `mangled` is this package's `check`. Matching the package-export
24/// mangled name is what makes the answer independent of how the symbol was
25/// imported: a named, an aliased and a namespace import all carry it, while a
26/// user's own `check` never can.
27pub fn is_check(mangled: &MangledName) -> bool {
28    *mangled == crate::mangle::package_symbol(MODULE_NAME, "check")
29}
30
31/// `toJson` is slot 1 of the four-slot `$VTable`.
32const TO_JSON_SLOT: usize = 1;
33
34pub fn package_declaration() -> PackageDeclaration {
35    let mut defs = PackageDeclaration::with_package(MODULE_NAME);
36    defs.values.insert(
37        "check".to_string(),
38        ValueSymbol {
39            name: "check".to_string(),
40            mangled_name: crate::mangle::package_symbol(MODULE_NAME, "check"),
41            declaration_span: Span::at(crate::FileId::SECURITY),
42            kind: ValueKind::Function {
43                generics: vec!["T".to_string()],
44                params: vec![
45                    Param::new("capability", Type::String),
46                    Param::new("context", Type::TypeVar("T".to_string())),
47                ],
48                ret: Type::Void,
49                type_predicate: None,
50                doc: crate::doc(
51                    crate::FileId::SECURITY,
52                    "/**\n * Semantic security check. Invokes the embedder's policy engine; \
53                     throws a catchable `PermissionDeniedError` if the policy denies the \
54                     call.\n * @param capability Dotted \
55                     capability identifier (e.g. `\"test.com/op\"`).\n * @param context \
56                     Arbitrary value (typically an object) whose fields the policy matches \
57                     against rules. Serialized to JSON at the host boundary.\n */",
58                ),
59            },
60        },
61    );
62    defs
63}
64
65pub fn install(linker: &mut Linker<StoreData>) -> wasmtime::Result<()> {
66    let engine = linker.engine().clone();
67    let intr = build_intrinsic_types(&engine)?;
68    let string = ValType::Ref(RefType::new(false, HeapType::ConcreteStruct(intr.string)));
69    // The erased-generic `context: T` lowers to `(ref null $Object)`.
70    let nullable_object = ValType::Ref(RefType::new(true, HeapType::ConcreteStruct(intr.object)));
71    let ty = FuncType::new(&engine, [string, nullable_object], []);
72    register_host_fn_async(
73        linker,
74        MODULE_NAME,
75        crate::mangle::package_symbol(MODULE_NAME, "check"),
76        ty,
77        /* deterministic = */ false,
78        |caller, params, _results| {
79            Box::pin(async move {
80                let capability = read_string_arg(
81                    &mut *caller,
82                    abi_arg(params, 0)?,
83                    "security.check (capability)",
84                )?;
85                if matches!(*abi_arg(params, 1)?, Val::AnyRef(None)) {
86                    wasmtime::bail!("security.check: context must not be null");
87                }
88                let json_val =
89                    dispatch_vtable_slot(caller, abi_arg(params, 1)?, TO_JSON_SLOT, &[]).await?;
90                let context_json =
91                    read_string_arg(&mut *caller, &json_val, "security.check (context)")?;
92                fuel::charge(&mut *caller, fuel::PARSE, context_json.len() as u64)?;
93                let context: serde_json::Value =
94                    serde_json::from_str(&context_json).map_err(|e| {
95                        wasmtime::Error::msg(format!("security.check: malformed context JSON: {e}"))
96                    })?;
97                fuel::charge_host_fuel(&mut *caller, fuel::GATE)?;
98                let who = consumer_of_running_package(&mut *caller, &capability).inspect_err(
99                    |error| {
100                        audit_consumer_failure(
101                            caller.data().security_check.as_ref(),
102                            &context,
103                            error,
104                            || crate::stdlib::shared::source_line(&*caller),
105                        );
106                    },
107                )?;
108                let policy = caller.data().security_check.clone();
109                let cwd = caller.data().vfs.cwd().to_owned();
110                let ticket =
111                    crate::stdlib::shared::begin_recorded_call(&*caller, &who, &capability);
112                let site = CallSite::new(ticket, EntryPath::PackageCheck);
113                crate::stdlib::shared::check_and_audit(
114                    policy.as_ref(),
115                    &who,
116                    &capability,
117                    &context,
118                    &cwd,
119                    site,
120                )
121                .map_err(|reason| permission_denied(who, capability, reason))
122            })
123        },
124    )
125}
126
127/// Who is asking the package that called `check`, walking out from the innermost wasm frame
128/// to the first frame owned by a different package.
129///
130/// Unlike a gated stdlib op — attributed to its immediate caller — an explicit `check` gates
131/// the *consumer* of the package that runs it: a library checking `test.op` asks "may my
132/// caller do this?", so attribution is one below the running code.
133///
134/// **Which caller, once a closure crosses a package boundary?** The running code and the
135/// package last entered have different owners then, and two readings exist. This picks the
136/// *immediate invoker* — the first differing frame walking outward — over pointing further up
137/// the chain, because it is what the frame walk naturally produces, because a defensive check
138/// wants to know who actually triggered this code, and because the alternative builds a
139/// confused deputy: a package would answer for work it did not request.
140///
141/// **`main` running is an error only with a package beneath it.** That is the confused-deputy
142/// shape — main-authored code reached through a package, where naming the package would let
143/// main speak for it. It is the escalation this work closes.
144///
145/// Both unknown-principal paths refuse rather than guess, matching [`running_package`]: no
146/// frames at all, and any frame in the walk whose module declares no name.
147///
148/// A script asking about *itself*, with no package on the stack, is answered rather than
149/// refused. It reads the operator's own `main:` rules and grants nothing: `check` performs no
150/// side effect, and every real operation is gated independently by `check_security`, which has
151/// no `main` exception. Refusing it would silently strip a documented, exercised capability —
152/// and would make an operator's `main:` rule for that capability dead on arrival — while
153/// closing no hole, since the confused-deputy case is caught by the guard above regardless.
154fn consumer_of_running_package(
155    store: &mut impl wasmtime::AsContextMut<Data = StoreData>,
156    capability: &str,
157) -> wasmtime::Result<String> {
158    let available = store
159        .as_context()
160        .get_fuel()?
161        .saturating_sub(store.as_context().data().host_fuel_pending);
162    let mut work = 0_u64;
163    let mut exhausted = false;
164    let mut running: Option<String> = None;
165    let mut below = None;
166    let mut unknown = None;
167    let walk = wasmtime::WasmBacktrace::visit_modules(&*store, |module| {
168        let step = fuel::ELEM.cost(1);
169        if step > available.saturating_sub(work) {
170            exhausted = true;
171            return std::ops::ControlFlow::Break(());
172        }
173        work += step;
174        let Some(owner) = module.name() else {
175            unknown = Some(permission_denied_invariant(
176                "<unnamed module>",
177                capability,
178                "a frame between the running code and its caller declares no package name, so the caller cannot be identified",
179            ));
180            return std::ops::ControlFlow::Break(());
181        };
182        if let Some(running) = &running {
183            if owner != running {
184                match crate::stdlib::shared::owned_principal(owner) {
185                    Ok(owner) => below = Some(owner),
186                    Err(error) => unknown = Some(error),
187                }
188                return std::ops::ControlFlow::Break(());
189            }
190        } else {
191            match crate::stdlib::shared::owned_principal(owner) {
192                Ok(owner) => running = Some(owner),
193                Err(error) => {
194                    unknown = Some(error);
195                    return std::ops::ControlFlow::Break(());
196                }
197            }
198        }
199        std::ops::ControlFlow::Continue(())
200    });
201    fuel::charge_host_fuel(&mut *store, work)?;
202    if exhausted {
203        fuel::charge_host_fuel(&mut *store, fuel::ELEM.cost(1))?;
204    }
205    walk.map_err(crate::runtime::host::fatal_host_error)?;
206    if let Some(error) = unknown {
207        return Err(error);
208    }
209    let running = running.ok_or_else(|| {
210        permission_denied_invariant(
211            "<no wasm frame>",
212            capability,
213            "no wasm frame is executing, so there is no caller to gate",
214        )
215    })?;
216    if running == crate::mangle::USER_PACKAGE && below.is_some() {
217        // Invariant, not policy: no rule can grant this, so the message must not send the
218        // reader off to ask the operator for one.
219        return Err(permission_denied_invariant(
220            &running,
221            capability,
222            "security.check gates the caller of the package that runs it. This is main's own \
223             code running inside a package, which has no caller to gate — naming the package \
224             here would let main-authored code speak for it",
225        ));
226    }
227    // No differing frame means one principal owns the whole stack: a script asking about
228    // itself, a package's test file calling into its library, or an initializer with nothing
229    // above it. The consumer sits outside the guest, which is the script's position.
230    match below {
231        Some(owner) => Ok(owner),
232        None => crate::stdlib::shared::owned_principal(crate::mangle::USER_PACKAGE),
233    }
234}
235
236/// Audits an attribution failure at the entry of `security.check` as a call of its own.
237/// `line` runs only while a recorder is installed.
238fn audit_consumer_failure(
239    policy: &dyn crate::runtime::security::SecurityCheck,
240    context: &serde_json::Value,
241    error: &wasmtime::Error,
242    line: impl FnOnce() -> Option<crate::runtime::decision::SourceLine>,
243) {
244    let Some(denial) = error.downcast_ref::<crate::runtime::host::PermissionDenied>() else {
245        return;
246    };
247    let ticket = policy
248        .recorder()
249        .map(|recorder| recorder.begin_call(&denial.caller, &denial.capability, line()));
250    crate::stdlib::shared::audit_denial_at(
251        policy,
252        &denial.caller,
253        &denial.capability,
254        context,
255        "invariant",
256        &denial.reason,
257        CallSite::new(ticket, EntryPath::PackageCheck),
258    );
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264    use crate::runtime::security::CheckOutcome;
265
266    #[test]
267    fn consumer_audit_distinguishes_attribution_denials_from_execution_failures() {
268        use crate::runtime::security::{AuditDecision, SecurityCheck};
269        use std::sync::Mutex;
270        #[derive(Default)]
271        struct AuditSink(Mutex<Vec<(String, String)>>);
272        impl SecurityCheck for AuditSink {
273            fn check(&self, _: &str, _: &str, _: &serde_json::Value) -> CheckOutcome {
274                CheckOutcome::Allow { rule: None }
275            }
276            fn audit(&self, decision: AuditDecision<'_>) {
277                self.0.lock().unwrap().push((
278                    decision.caller.to_owned(),
279                    decision.reason.unwrap().to_owned(),
280                ));
281            }
282        }
283        let sink = AuditSink::default();
284        let context = serde_json::Value::Null;
285        audit_consumer_failure(
286            &sink,
287            &context,
288            &wasmtime::Error::new(wasmtime::Trap::OutOfFuel),
289            || None,
290        );
291        audit_consumer_failure(
292            &sink,
293            &context,
294            &crate::runtime::host::fatal_host_error("engine failure"),
295            || None,
296        );
297        assert!(sink.0.lock().unwrap().is_empty());
298        for label in ["<no wasm frame>", "<unnamed module>"] {
299            audit_consumer_failure(
300                &sink,
301                &context,
302                &permission_denied_invariant(label, "test.op", "unattributed caller"),
303                || None,
304            );
305        }
306        assert_eq!(
307            *sink.0.lock().unwrap(),
308            vec![
309                (
310                    "<no wasm frame>".to_owned(),
311                    "unattributed caller".to_owned()
312                ),
313                (
314                    "<unnamed module>".to_owned(),
315                    "unattributed caller".to_owned()
316                ),
317            ]
318        );
319    }
320
321    #[tokio::test]
322    async fn necessary_caller_walk_is_metered_and_stops_before_short_fuel_work() {
323        use std::sync::{
324            Arc, Mutex,
325            atomic::{AtomicBool, Ordering},
326        };
327        let config = crate::runtime::RuntimeConfig::default();
328        let engine = config.engine().unwrap();
329        let mut store = config
330            .store_async(&engine, StoreData::with_vfs(crate::runtime::Vfs::none()))
331            .unwrap();
332        let costs = Arc::new(Mutex::new(Vec::new()));
333        let measured = Arc::clone(&costs);
334        let short = Arc::new(AtomicBool::new(false));
335        let limited = Arc::clone(&short);
336        let mut linker = Linker::new(&engine);
337        linker
338            .func_new(
339                "host",
340                "check",
341                FuncType::new(&engine, [], []),
342                move |mut caller, _, _| {
343                    if limited.load(Ordering::Relaxed) {
344                        caller.set_fuel(5)?;
345                    }
346                    assert_eq!(
347                        crate::stdlib::shared::running_package(&caller)
348                            .map_err(|error| error.into_denial("test.op"))?,
349                        "main"
350                    );
351                    let before = caller.data().host_fuel;
352                    assert_eq!(consumer_of_running_package(&mut caller, "test.op")?, "main");
353                    measured
354                        .lock()
355                        .unwrap()
356                        .push(caller.data().host_fuel - before);
357                    Ok(())
358                },
359            )
360            .unwrap();
361        let source = r#"(module $main
362            (import "host" "check" (func $check))
363            (func $walk (export "walk") (param $depth i32)
364                local.get $depth i32.eqz
365                if call $check else local.get $depth i32.const 1 i32.sub call $walk end))"#;
366        let buffer = wast::parser::ParseBuffer::new(source).unwrap();
367        let mut wat = wast::parser::parse::<wast::Wat>(&buffer).unwrap();
368        let wasm = wat.encode().unwrap();
369        let module = wasmtime::Module::new(&engine, wasm).unwrap();
370        let instance = linker.instantiate_async(&mut store, &module).await.unwrap();
371        let walk = instance.get_func(&mut store, "walk").unwrap();
372        for depth in [32, 64] {
373            walk.call_async(&mut store, &[Val::I32(depth)], &mut [])
374                .await
375                .unwrap();
376        }
377        assert_eq!(
378            *costs.lock().unwrap(),
379            vec![fuel::ELEM.cost(33), fuel::ELEM.cost(65)]
380        );
381        short.store(true, Ordering::Relaxed);
382        let error = walk
383            .call_async(&mut store, &[Val::I32(64)], &mut [])
384            .await
385            .unwrap_err();
386        assert_eq!(
387            error.downcast_ref::<wasmtime::Trap>(),
388            Some(&wasmtime::Trap::OutOfFuel)
389        );
390        assert_eq!(store.get_fuel().unwrap(), 0);
391        short.store(false, Ordering::Relaxed);
392        store.set_fuel(1_000_000).unwrap();
393        walk.call_async(&mut store, &[Val::I32(32)], &mut [])
394            .await
395            .unwrap();
396    }
397
398    /// One package owns every frame — a package's own test file calling into its library, or
399    /// an initializer with nothing above it. There is no "one below" to name, and the walk
400    /// must not return the running package as its own consumer. The consumer sits outside the
401    /// guest, which is the script's position.
402    #[tokio::test]
403    async fn a_package_alone_on_the_stack_answers_main() {
404        let source = "import { check } from \"submilli:security\";\n\n\
405                      function main(): void {\n  check(\"test.com/op\", { foo: 1 });\n}\n";
406        let compiled = crate::compile::compile_script_owned_by(
407            "test:solo",
408            source,
409            "script.subm",
410            crate::FileId(0),
411            &[],
412            &[],
413        )
414        .expect("compile clean");
415        let cfg = crate::runtime::RuntimeConfig::default();
416        let engine = cfg.engine().expect("engine");
417        let mut data =
418            crate::runtime::StoreData::with_vfs(crate::runtime::Vfs::tempdir().expect("tempdir"));
419        data.install_type_info(compiled.type_info.clone());
420        data.security_check = std::sync::Arc::new(DenyAll);
421        let mut store = cfg.store(&engine, data).expect("store");
422        let module = wasmtime::Module::new(&engine, &compiled.wasm).expect("module");
423        let mut linker = wasmtime::Linker::<StoreData>::new(&engine);
424        crate::runtime::install_runtime_async(&mut linker, &mut store)
425            .await
426            .expect("install");
427        let inst = linker
428            .instantiate_async(&mut store, &module)
429            .await
430            .expect("instantiate");
431        let err = crate::runtime::dispatch_main_async(&mut store, &inst)
432            .await
433            .expect_err("DenyAll refuses");
434        assert!(
435            format!("{err}").contains("caller=main"),
436            "a lone package's consumer is the script; got: {err}"
437        );
438    }
439
440    /// The case the guard exists for, and the only shape `check` refuses: `main`-authored code
441    /// reached through a package. Here `main`'s `toJson` runs inside the package's
442    /// `JSON.stringify`, so the running code is `main`'s with a package beneath it. Answering
443    /// would name that package and let `main`-authored code speak for it — the escalation this
444    /// work closes, arriving at `security.check` instead of at a gated host fn.
445    #[tokio::test]
446    async fn main_authored_code_inside_a_package_cannot_ask_on_the_packages_behalf() {
447        let (lib_bytes, lib_decl, lib_type_info) = crate::codegen::tests::compile_package_modules(
448            "test:wrap",
449            &[(
450                "lib",
451                r#"
452                /**
453                 * Pass-through JSON encoder.
454                 * @param value Value to encode.
455                 * @returns `value` as JSON, or `undefined` when it has none.
456                 */
457                export function passthrough(value: unknown): string | undefined {
458                    return JSON.stringify(value);
459                }
460                "#,
461            )],
462            &[],
463        );
464        let cfg = crate::runtime::RuntimeConfig::default();
465        let engine = cfg.engine().expect("engine");
466        let mut data =
467            crate::runtime::StoreData::with_vfs(crate::runtime::Vfs::tempdir().expect("tempdir"));
468        data.install_type_info(lib_type_info);
469        let mut store = cfg.store(&engine, data).expect("store");
470        let mut linker = wasmtime::Linker::<StoreData>::new(&engine);
471        crate::runtime::install_runtime_async(&mut linker, &mut store)
472            .await
473            .expect("install");
474        let lib_module = wasmtime::Module::new(&engine, &lib_bytes).expect("library module");
475        let lib_inst = linker
476            .instantiate_async(&mut store, &lib_module)
477            .await
478            .expect("instantiate library");
479        linker
480            .instance(&mut store, "test:wrap", lib_inst)
481            .expect("register library instance");
482        let public_name = crate::mangle::package_symbol("test:wrap", "passthrough");
483        let func = lib_inst
484            .get_func(&mut store, public_name.as_str())
485            .expect("library public export");
486        linker
487            .define(&mut store, "test:wrap", "passthrough", func)
488            .expect("plain package import alias");
489
490        let consumer = crate::compile_script(
491            r#"
492            import { passthrough } from "test:wrap";
493            import { check } from "submilli:security";
494
495            function gate(): void {
496                check("test.com/op", { foo: 1 });
497            }
498
499            class Probe {
500                seen: number;
501                constructor() { this.seen = 0; }
502                toJson(): string {
503                    gate();
504                    this.seen = 1;
505                    return "\"ok\"";
506                }
507            }
508
509            function main(): number {
510                const p = new Probe();
511                const _ = passthrough(p);
512                return p.seen;
513            }
514            "#,
515            "consumer.subm",
516            crate::FileId(0),
517            &[&lib_decl],
518            &[],
519        )
520        .expect("consumer compiles");
521        store
522            .data_mut()
523            .install_type_info(consumer.type_info.clone());
524        let consumer_module = wasmtime::Module::new(&engine, &consumer.wasm).expect("module");
525        let inst = linker
526            .instantiate_async(&mut store, &consumer_module)
527            .await
528            .expect("instantiate consumer");
529        let err = crate::runtime::dispatch_main_async(&mut store, &inst)
530            .await
531            .expect_err("main-authored code inside a package may not ask");
532        let msg = format!("{err}");
533        assert!(
534            msg.contains("no caller to gate") && !msg.contains("caller=test:wrap"),
535            "must refuse rather than name the package: {msg}"
536        );
537    }
538
539    /// Rebuilds `bytes` without its `name` custom section, producing a module with no declared
540    /// principal — the shape an embedder can hand to the raw-bytes API, which codegen itself
541    /// never emits.
542    fn strip_name_section(bytes: &[u8]) -> Vec<u8> {
543        use wasmparser::{Parser, Payload};
544        let mut out = bytes[..8].to_vec();
545        for payload in Parser::new(0).parse_all(bytes) {
546            let payload = payload.expect("payload");
547            if let Payload::CustomSection(reader) = &payload
548                && reader.name() == "name"
549            {
550                continue;
551            }
552            if let Some((id, range)) = payload.as_section() {
553                out.push(id);
554                let mut len = Vec::new();
555                leb128_write(&mut len, range.len() as u64);
556                out.extend_from_slice(&len);
557                out.extend_from_slice(&bytes[range]);
558            }
559        }
560        out
561    }
562
563    fn leb128_write(out: &mut Vec<u8>, mut value: u64) {
564        loop {
565            let mut byte = (value & 0x7f) as u8;
566            value >>= 7;
567            if value != 0 {
568                byte |= 0x80;
569            }
570            out.push(byte);
571            if value == 0 {
572                return;
573            }
574        }
575    }
576
577    /// A module with no `name` section has no principal to speak for, so a gated call from it
578    /// must be refused rather than attributed. Reachable through the embeddable API, which
579    /// accepts raw wasm bytes this compiler did not produce.
580    ///
581    /// This is the assertion whose absence let `security.check`'s walk silently skip unnamed
582    /// frames instead of failing closed.
583    #[tokio::test]
584    async fn a_module_without_a_name_is_refused_rather_than_attributed() {
585        let source = r#"
586            import { get } from "submilli:secrets";
587            function main(): void { const _ = get("TOKEN"); }
588        "#;
589        let compiled = crate::compile_script(source, "anon.subm", crate::FileId(0), &[], &[])
590            .expect("compile clean");
591        let stripped = strip_name_section(&compiled.wasm);
592
593        let cfg = crate::runtime::RuntimeConfig::default();
594        let engine = cfg.engine().expect("engine");
595        let module = wasmtime::Module::new(&engine, &stripped).expect("stripped module validates");
596        assert_eq!(
597            module.name(),
598            None,
599            "the fixture must genuinely lack a module name, or it proves nothing"
600        );
601
602        let mut data =
603            crate::runtime::StoreData::with_vfs(crate::runtime::Vfs::tempdir().expect("tempdir"));
604        data.install_type_info(compiled.type_info.clone());
605        let mut store = cfg.store(&engine, data).expect("store");
606        let mut linker = wasmtime::Linker::<StoreData>::new(&engine);
607        crate::runtime::install_runtime_async(&mut linker, &mut store)
608            .await
609            .expect("install");
610        let inst = linker
611            .instantiate_async(&mut store, &module)
612            .await
613            .expect("instantiate");
614        let err = crate::runtime::dispatch_main_async(&mut store, &inst)
615            .await
616            .expect_err("an unnameable principal must be refused");
617        let msg = format!("{err}");
618        assert!(
619            msg.contains("<unnamed module>"),
620            "must name the unresolvable principal rather than defaulting to one: {msg}"
621        );
622        assert!(
623            !msg.contains("caller=main"),
624            "must not fall back to main: {msg}"
625        );
626    }
627
628    /// The same unnameable principal reaching `security.check` rather than a gated op. Before
629    /// the walk failed closed it dropped unnamed frames from consideration, so this reported
630    /// `<no wasm frame>` — claiming nothing was executing while a frame plainly was. The label
631    /// is the discriminator.
632    ///
633    /// Not covered here: an unnamed frame *between* two named ones. That needs a linked module
634    /// this compiler cannot emit, so the walk's mid-stack guard rests on the code, not a test.
635    #[tokio::test]
636    async fn security_check_from_an_unnamed_module_names_the_right_failure() {
637        let source = r#"
638            import { check } from "submilli:security";
639            function main(): void { check("test.com/op", { foo: 1 }); }
640        "#;
641        let compiled = crate::compile_script(source, "anon.subm", crate::FileId(0), &[], &[])
642            .expect("compile clean");
643        let stripped = strip_name_section(&compiled.wasm);
644
645        let cfg = crate::runtime::RuntimeConfig::default();
646        let engine = cfg.engine().expect("engine");
647        let module = wasmtime::Module::new(&engine, &stripped).expect("module validates");
648        let mut data =
649            crate::runtime::StoreData::with_vfs(crate::runtime::Vfs::tempdir().expect("tempdir"));
650        data.install_type_info(compiled.type_info.clone());
651        let mut store = cfg.store(&engine, data).expect("store");
652        let mut linker = wasmtime::Linker::<StoreData>::new(&engine);
653        crate::runtime::install_runtime_async(&mut linker, &mut store)
654            .await
655            .expect("install");
656        let inst = linker
657            .instantiate_async(&mut store, &module)
658            .await
659            .expect("instantiate");
660        let err = crate::runtime::dispatch_main_async(&mut store, &inst)
661            .await
662            .expect_err("an unnameable principal must be refused");
663        let msg = format!("{err}");
664        assert!(
665            msg.contains("<unnamed module>"),
666            "must name the running module as the unresolvable one, not claim no frame exists: \
667             {msg}"
668        );
669        // An invariant, not a policy outcome — no rule can grant it, so the message must not
670        // send the reader off to ask the operator for one.
671        assert!(
672            !msg.contains("operator's policy"),
673            "an unresolvable principal is an invariant refusal, not a policy denial: {msg}"
674        );
675    }
676
677    /// An unnamed frame *between* the running code and its caller. `@test/inner` runs the
678    /// check and is named; the relay that invoked it is not; `main` is named beyond that.
679    ///
680    /// The walk must refuse here rather than skip the unnameable frame and name `main` — that
681    /// would attribute the call to something two hops out that never invoked the check, which
682    /// is the confused deputy the guard exists to prevent. Before the walk failed closed it
683    /// did exactly that.
684    ///
685    /// `install_package_modules_async` now refuses to link an unnamed package, so this is
686    /// unreachable in production; the modules are linked by hand to reach the guard directly.
687    #[tokio::test]
688    async fn an_unnamed_frame_between_caller_and_callee_is_refused() {
689        let (inner_bytes, inner_decl, inner_ti) = crate::codegen::tests::compile_package_modules(
690            "test:inner",
691            &[(
692                "lib",
693                r#"
694                import { check } from "submilli:security";
695
696                /**
697                 * Gates an operation on behalf of whoever called it.
698                 * @param foo Value the capability filter matches.
699                 * @capability test.com/op { foo }
700                 */
701                export function guarded(foo: number): void {
702                    check("test.com/op", { foo });
703                }
704                "#,
705            )],
706            &[],
707        );
708        let (relay_bytes, relay_decl, relay_ti) = crate::codegen::tests::compile_package_modules(
709            "test:relay",
710            &[(
711                "lib",
712                r#"
713                import { guarded } from "test:inner";
714
715                /**
716                 * Calls the inner package, standing between it and the script.
717                 * @param foo Value passed to the inner package.
718                 */
719                export function relay(foo: number): void {
720                    guarded(foo);
721                }
722                "#,
723            )],
724            &[&inner_decl],
725        );
726        // The middle frame loses its identity; the other two keep theirs.
727        let relay_bytes = strip_name_section(&relay_bytes);
728
729        let cfg = crate::runtime::RuntimeConfig::default();
730        let engine = cfg.engine().expect("engine");
731        let mut data =
732            crate::runtime::StoreData::with_vfs(crate::runtime::Vfs::tempdir().expect("tempdir"));
733        data.install_type_info(inner_ti);
734        data.install_type_info(relay_ti);
735        let mut store = cfg.store(&engine, data).expect("store");
736        let mut linker = wasmtime::Linker::<StoreData>::new(&engine);
737        crate::runtime::install_runtime_async(&mut linker, &mut store)
738            .await
739            .expect("install");
740
741        let inner_module = wasmtime::Module::new(&engine, &inner_bytes).expect("inner module");
742        let inner_inst = linker
743            .instantiate_async(&mut store, &inner_module)
744            .await
745            .expect("instantiate inner");
746        linker
747            .instance(&mut store, "test:inner", inner_inst)
748            .expect("register inner");
749
750        let relay_module = wasmtime::Module::new(&engine, &relay_bytes).expect("relay module");
751        assert_eq!(
752            relay_module.name(),
753            None,
754            "the middle module must genuinely lack a name, or the test proves nothing"
755        );
756        let relay_inst = linker
757            .instantiate_async(&mut store, &relay_module)
758            .await
759            .expect("instantiate relay");
760        linker
761            .instance(&mut store, "test:relay", relay_inst)
762            .expect("register relay");
763        let public_name = crate::mangle::package_symbol("test:relay", "relay");
764        let relay_fn = relay_inst
765            .get_func(&mut store, public_name.as_str())
766            .expect("relay export");
767        linker
768            .define(&mut store, "test:relay", "relay", relay_fn)
769            .expect("plain package import alias");
770
771        let consumer = crate::compile_script(
772            r#"
773            import { relay } from "test:relay";
774            function main(): void { relay(1); }
775            "#,
776            "consumer.subm",
777            crate::FileId(0),
778            &[&relay_decl],
779            &[],
780        )
781        .expect("consumer compiles");
782        store
783            .data_mut()
784            .install_type_info(consumer.type_info.clone());
785        let consumer_module = wasmtime::Module::new(&engine, &consumer.wasm).expect("module");
786        let inst = linker
787            .instantiate_async(&mut store, &consumer_module)
788            .await
789            .expect("instantiate consumer");
790        let err = crate::runtime::dispatch_main_async(&mut store, &inst)
791            .await
792            .expect_err("an unnameable frame in the walk must be refused");
793        let msg = format!("{err}");
794        assert!(
795            msg.contains("<unnamed module>"),
796            "must refuse on the unnameable frame: {msg}"
797        );
798        assert!(
799            !msg.contains("caller=main"),
800            "must not skip the unnameable frame and name main: {msg}"
801        );
802    }
803
804    #[test]
805    fn module_name_matches_runtime_constant() {
806        // Kept stable for any external embedders that reference the
807        // constant by re-export from `crate::runtime`.
808        assert_eq!(MODULE_NAME, "submilli:security");
809    }
810
811    struct DenyAll;
812    impl crate::runtime::SecurityCheck for DenyAll {
813        fn check(
814            &self,
815            _caller: &str,
816            _capability: &str,
817            _context: &serde_json::Value,
818        ) -> CheckOutcome {
819            CheckOutcome::Deny {
820                rule: None,
821                reason: "blocked by policy".to_string(),
822            }
823        }
824    }
825
826    #[tokio::test]
827    async fn uncaught_denial_renders_message_and_backtrace() {
828        let source = "import { check } from \"submilli:security\";\n\n\
829                      function main(): void {\n  check(\"test.com/op\", { foo: 1 });\n}\n";
830        let compiled = crate::compile_script(source, "script.subm", crate::FileId(0), &[], &[])
831            .expect("compile clean");
832        let cfg = crate::runtime::RuntimeConfig::default();
833        let engine = cfg.engine().expect("engine");
834        let mut data =
835            crate::runtime::StoreData::with_vfs(crate::runtime::Vfs::tempdir().expect("tempdir"));
836        data.install_type_info(compiled.type_info.clone());
837        data.security_check = std::sync::Arc::new(DenyAll);
838        let mut store = cfg.store(&engine, data).expect("store");
839        let module = wasmtime::Module::new(&engine, &compiled.wasm).expect("module");
840        let mut linker = wasmtime::Linker::<StoreData>::new(&engine);
841        crate::runtime::install_runtime_async(&mut linker, &mut store)
842            .await
843            .expect("install");
844        let inst = linker
845            .instantiate_async(&mut store, &module)
846            .await
847            .expect("instantiate");
848        let err = crate::runtime::dispatch_main_async(&mut store, &inst)
849            .await
850            .expect_err("uncaught denial surfaces as a runtime error");
851
852        let msg = format!("{err}");
853        assert!(
854            msg.contains(
855                "PermissionDeniedError: permission denied: \
856                 caller=main capability=test.com/op: blocked by policy"
857            ),
858            "expected the class-prefixed denial message; got: {msg}"
859        );
860        assert!(
861            msg.contains("fields:")
862                && msg.contains("capability = \"test.com/op\"")
863                && msg.contains("caller = \"main\""),
864            "denial data fields should render on the fields line; got: {msg}"
865        );
866
867        let (sources, file) = crate::Sources::single("script.subm", source).unwrap();
868        let rendered =
869            crate::backtrace::render(&err, &sources, file, crate::backtrace::BacktraceMode::Full)
870                .expect("an uncaught denial should render a backtrace");
871        assert!(
872            rendered.contains("at main ("),
873            "backtrace should point at main: {rendered}"
874        );
875        assert!(
876            rendered.contains("check(\"test.com/op\""),
877            "source context should show the check() call site: {rendered}"
878        );
879    }
880}