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