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