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::call_origin(&*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/// `origin` 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    origin: impl FnOnce() -> crate::runtime::decision::CallOrigin,
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, origin()));
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::decision::CallOrigin;
265    use crate::runtime::security::CheckOutcome;
266
267    #[test]
268    fn consumer_audit_distinguishes_attribution_denials_from_execution_failures() {
269        use crate::runtime::security::{AuditDecision, SecurityCheck};
270        use std::sync::Mutex;
271        #[derive(Default)]
272        struct AuditSink(Mutex<Vec<(String, String)>>);
273        impl SecurityCheck for AuditSink {
274            fn check(&self, _: &str, _: &str, _: &serde_json::Value) -> CheckOutcome {
275                CheckOutcome::Allow { rule: None }
276            }
277            fn audit(&self, decision: AuditDecision<'_>) {
278                self.0.lock().unwrap().push((
279                    decision.caller.to_owned(),
280                    decision.reason.unwrap().to_owned(),
281                ));
282            }
283        }
284        let sink = AuditSink::default();
285        let context = serde_json::Value::Null;
286        audit_consumer_failure(
287            &sink,
288            &context,
289            &wasmtime::Error::new(wasmtime::Trap::OutOfFuel),
290            CallOrigin::default,
291        );
292        audit_consumer_failure(
293            &sink,
294            &context,
295            &crate::runtime::host::fatal_host_error("engine failure"),
296            CallOrigin::default,
297        );
298        assert!(sink.0.lock().unwrap().is_empty());
299        for label in ["<no wasm frame>", "<unnamed module>"] {
300            audit_consumer_failure(
301                &sink,
302                &context,
303                &permission_denied_invariant(label, "test.op", "unattributed caller"),
304                CallOrigin::default,
305            );
306        }
307        assert_eq!(
308            *sink.0.lock().unwrap(),
309            vec![
310                (
311                    "<no wasm frame>".to_owned(),
312                    "unattributed caller".to_owned()
313                ),
314                (
315                    "<unnamed module>".to_owned(),
316                    "unattributed caller".to_owned()
317                ),
318            ]
319        );
320    }
321
322    #[tokio::test]
323    async fn necessary_caller_walk_is_metered_and_stops_before_short_fuel_work() {
324        use std::sync::{
325            Arc, Mutex,
326            atomic::{AtomicBool, Ordering},
327        };
328        let config = crate::runtime::RuntimeConfig::default();
329        let engine = config.engine().unwrap();
330        let mut store = config
331            .store_async(&engine, StoreData::with_vfs(crate::runtime::Vfs::none()))
332            .unwrap();
333        let costs = Arc::new(Mutex::new(Vec::new()));
334        let measured = Arc::clone(&costs);
335        let short = Arc::new(AtomicBool::new(false));
336        let limited = Arc::clone(&short);
337        let mut linker = Linker::new(&engine);
338        linker
339            .func_new(
340                "host",
341                "check",
342                FuncType::new(&engine, [], []),
343                move |mut caller, _, _| {
344                    if limited.load(Ordering::Relaxed) {
345                        caller.set_fuel(5)?;
346                    }
347                    assert_eq!(
348                        crate::stdlib::shared::running_package(&caller)
349                            .map_err(|error| error.into_denial("test.op"))?,
350                        "main"
351                    );
352                    let before = caller.data().host_fuel;
353                    assert_eq!(consumer_of_running_package(&mut caller, "test.op")?, "main");
354                    measured
355                        .lock()
356                        .unwrap()
357                        .push(caller.data().host_fuel - before);
358                    Ok(())
359                },
360            )
361            .unwrap();
362        let source = r#"(module $main
363            (import "host" "check" (func $check))
364            (func $walk (export "walk") (param $depth i32)
365                local.get $depth i32.eqz
366                if call $check else local.get $depth i32.const 1 i32.sub call $walk end))"#;
367        let buffer = wast::parser::ParseBuffer::new(source).unwrap();
368        let mut wat = wast::parser::parse::<wast::Wat>(&buffer).unwrap();
369        let wasm = wat.encode().unwrap();
370        let module = wasmtime::Module::new(&engine, wasm).unwrap();
371        let instance = linker.instantiate_async(&mut store, &module).await.unwrap();
372        let walk = instance.get_func(&mut store, "walk").unwrap();
373        for depth in [32, 64] {
374            walk.call_async(&mut store, &[Val::I32(depth)], &mut [])
375                .await
376                .unwrap();
377        }
378        assert_eq!(
379            *costs.lock().unwrap(),
380            vec![fuel::ELEM.cost(33), fuel::ELEM.cost(65)]
381        );
382        short.store(true, Ordering::Relaxed);
383        let error = walk
384            .call_async(&mut store, &[Val::I32(64)], &mut [])
385            .await
386            .unwrap_err();
387        assert_eq!(
388            error.downcast_ref::<wasmtime::Trap>(),
389            Some(&wasmtime::Trap::OutOfFuel)
390        );
391        assert_eq!(store.get_fuel().unwrap(), 0);
392        short.store(false, Ordering::Relaxed);
393        store.set_fuel(1_000_000).unwrap();
394        walk.call_async(&mut store, &[Val::I32(32)], &mut [])
395            .await
396            .unwrap();
397    }
398
399    /// One package owns every frame — a package's own test file calling into its library, or
400    /// an initializer with nothing above it. There is no "one below" to name, and the walk
401    /// must not return the running package as its own consumer. The consumer sits outside the
402    /// guest, which is the script's position.
403    #[tokio::test]
404    async fn a_package_alone_on_the_stack_answers_main() {
405        let source = "import { check } from \"submilli:security\";\n\n\
406                      function main(): void {\n  check(\"test.com/op\", { foo: 1 });\n}\n";
407        let compiled = crate::compile::compile_script_owned_by(
408            "test:solo",
409            source,
410            "script.subm",
411            crate::FileId(0),
412            &[],
413            &[],
414        )
415        .expect("compile clean");
416        let cfg = crate::runtime::RuntimeConfig::default();
417        let engine = cfg.engine().expect("engine");
418        let mut data =
419            crate::runtime::StoreData::with_vfs(crate::runtime::Vfs::tempdir().expect("tempdir"));
420        data.install_type_info(compiled.type_info.clone());
421        data.security_check = std::sync::Arc::new(DenyAll);
422        let mut store = cfg.store(&engine, data).expect("store");
423        let module = wasmtime::Module::new(&engine, &compiled.wasm).expect("module");
424        let mut linker = wasmtime::Linker::<StoreData>::new(&engine);
425        crate::runtime::install_runtime_async(&mut linker, &mut store)
426            .await
427            .expect("install");
428        let inst = linker
429            .instantiate_async(&mut store, &module)
430            .await
431            .expect("instantiate");
432        let err = crate::runtime::dispatch_main_async(&mut store, &inst)
433            .await
434            .expect_err("DenyAll refuses");
435        assert!(
436            format!("{err}").contains("caller=main"),
437            "a lone package's consumer is the script; got: {err}"
438        );
439    }
440
441    /// The case the guard exists for, and the only shape `check` refuses: `main`-authored code
442    /// reached through a package. Here `main`'s `toJson` runs inside the package's
443    /// `JSON.stringify`, so the running code is `main`'s with a package beneath it. Answering
444    /// would name that package and let `main`-authored code speak for it — the escalation this
445    /// work closes, arriving at `security.check` instead of at a gated host fn.
446    #[tokio::test]
447    async fn main_authored_code_inside_a_package_cannot_ask_on_the_packages_behalf() {
448        let (lib_bytes, lib_decl, lib_type_info) = crate::codegen::tests::compile_package_modules(
449            "test:wrap",
450            &[(
451                "lib",
452                r#"
453                /**
454                 * Pass-through JSON encoder.
455                 * @param value Value to encode.
456                 * @returns `value` as JSON, or `undefined` when it has none.
457                 */
458                export function passthrough(value: unknown): string | undefined {
459                    return JSON.stringify(value);
460                }
461                "#,
462            )],
463            &[],
464        );
465        let cfg = crate::runtime::RuntimeConfig::default();
466        let engine = cfg.engine().expect("engine");
467        let mut data =
468            crate::runtime::StoreData::with_vfs(crate::runtime::Vfs::tempdir().expect("tempdir"));
469        data.install_type_info(lib_type_info);
470        let mut store = cfg.store(&engine, data).expect("store");
471        let mut linker = wasmtime::Linker::<StoreData>::new(&engine);
472        crate::runtime::install_runtime_async(&mut linker, &mut store)
473            .await
474            .expect("install");
475        let lib_module = wasmtime::Module::new(&engine, &lib_bytes).expect("library module");
476        let lib_inst = linker
477            .instantiate_async(&mut store, &lib_module)
478            .await
479            .expect("instantiate library");
480        linker
481            .instance(&mut store, "test:wrap", lib_inst)
482            .expect("register library instance");
483        let public_name = crate::mangle::package_symbol("test:wrap", "passthrough");
484        let func = lib_inst
485            .get_func(&mut store, public_name.as_str())
486            .expect("library public export");
487        linker
488            .define(&mut store, "test:wrap", "passthrough", func)
489            .expect("plain package import alias");
490
491        let consumer = crate::compile_script(
492            r#"
493            import { passthrough } from "test:wrap";
494            import { check } from "submilli:security";
495
496            function gate(): void {
497                check("test.com/op", { foo: 1 });
498            }
499
500            class Probe {
501                seen: number;
502                constructor() { this.seen = 0; }
503                toJson(): string {
504                    gate();
505                    this.seen = 1;
506                    return "\"ok\"";
507                }
508            }
509
510            function main(): number {
511                const p = new Probe();
512                const _ = passthrough(p);
513                return p.seen;
514            }
515            "#,
516            "consumer.subm",
517            crate::FileId(0),
518            &[&lib_decl],
519            &[],
520        )
521        .expect("consumer compiles");
522        store
523            .data_mut()
524            .install_type_info(consumer.type_info.clone());
525        let consumer_module = wasmtime::Module::new(&engine, &consumer.wasm).expect("module");
526        let inst = linker
527            .instantiate_async(&mut store, &consumer_module)
528            .await
529            .expect("instantiate consumer");
530        let err = crate::runtime::dispatch_main_async(&mut store, &inst)
531            .await
532            .expect_err("main-authored code inside a package may not ask");
533        let msg = format!("{err}");
534        assert!(
535            msg.contains("no caller to gate") && !msg.contains("caller=test:wrap"),
536            "must refuse rather than name the package: {msg}"
537        );
538    }
539
540    /// Rebuilds `bytes` without its `name` custom section, producing a module with no declared
541    /// principal — the shape an embedder can hand to the raw-bytes API, which codegen itself
542    /// never emits.
543    fn strip_name_section(bytes: &[u8]) -> Vec<u8> {
544        use wasmparser::{Parser, Payload};
545        let mut out = bytes[..8].to_vec();
546        for payload in Parser::new(0).parse_all(bytes) {
547            let payload = payload.expect("payload");
548            if let Payload::CustomSection(reader) = &payload
549                && reader.name() == "name"
550            {
551                continue;
552            }
553            if let Some((id, range)) = payload.as_section() {
554                out.push(id);
555                let mut len = Vec::new();
556                leb128_write(&mut len, range.len() as u64);
557                out.extend_from_slice(&len);
558                out.extend_from_slice(&bytes[range]);
559            }
560        }
561        out
562    }
563
564    fn leb128_write(out: &mut Vec<u8>, mut value: u64) {
565        loop {
566            let mut byte = (value & 0x7f) as u8;
567            value >>= 7;
568            if value != 0 {
569                byte |= 0x80;
570            }
571            out.push(byte);
572            if value == 0 {
573                return;
574            }
575        }
576    }
577
578    /// A module with no `name` section has no principal to speak for, so a gated call from it
579    /// must be refused rather than attributed. Reachable through the embeddable API, which
580    /// accepts raw wasm bytes this compiler did not produce.
581    ///
582    /// This is the assertion whose absence let `security.check`'s walk silently skip unnamed
583    /// frames instead of failing closed.
584    #[tokio::test]
585    async fn a_module_without_a_name_is_refused_rather_than_attributed() {
586        let source = r#"
587            import { get } from "submilli:secrets";
588            function main(): void { const _ = get("TOKEN"); }
589        "#;
590        let compiled = crate::compile_script(source, "anon.subm", crate::FileId(0), &[], &[])
591            .expect("compile clean");
592        let stripped = strip_name_section(&compiled.wasm);
593
594        let cfg = crate::runtime::RuntimeConfig::default();
595        let engine = cfg.engine().expect("engine");
596        let module = wasmtime::Module::new(&engine, &stripped).expect("stripped module validates");
597        assert_eq!(
598            module.name(),
599            None,
600            "the fixture must genuinely lack a module name, or it proves nothing"
601        );
602
603        let mut data =
604            crate::runtime::StoreData::with_vfs(crate::runtime::Vfs::tempdir().expect("tempdir"));
605        data.install_type_info(compiled.type_info.clone());
606        let mut store = cfg.store(&engine, data).expect("store");
607        let mut linker = wasmtime::Linker::<StoreData>::new(&engine);
608        crate::runtime::install_runtime_async(&mut linker, &mut store)
609            .await
610            .expect("install");
611        let inst = linker
612            .instantiate_async(&mut store, &module)
613            .await
614            .expect("instantiate");
615        let err = crate::runtime::dispatch_main_async(&mut store, &inst)
616            .await
617            .expect_err("an unnameable principal must be refused");
618        let msg = format!("{err}");
619        assert!(
620            msg.contains("<unnamed module>"),
621            "must name the unresolvable principal rather than defaulting to one: {msg}"
622        );
623        assert!(
624            !msg.contains("caller=main"),
625            "must not fall back to main: {msg}"
626        );
627    }
628
629    /// The same unnameable principal reaching `security.check` rather than a gated op. Before
630    /// the walk failed closed it dropped unnamed frames from consideration, so this reported
631    /// `<no wasm frame>` — claiming nothing was executing while a frame plainly was. The label
632    /// is the discriminator.
633    ///
634    /// Not covered here: an unnamed frame *between* two named ones. That needs a linked module
635    /// this compiler cannot emit, so the walk's mid-stack guard rests on the code, not a test.
636    #[tokio::test]
637    async fn security_check_from_an_unnamed_module_names_the_right_failure() {
638        let source = r#"
639            import { check } from "submilli:security";
640            function main(): void { check("test.com/op", { foo: 1 }); }
641        "#;
642        let compiled = crate::compile_script(source, "anon.subm", crate::FileId(0), &[], &[])
643            .expect("compile clean");
644        let stripped = strip_name_section(&compiled.wasm);
645
646        let cfg = crate::runtime::RuntimeConfig::default();
647        let engine = cfg.engine().expect("engine");
648        let module = wasmtime::Module::new(&engine, &stripped).expect("module validates");
649        let mut data =
650            crate::runtime::StoreData::with_vfs(crate::runtime::Vfs::tempdir().expect("tempdir"));
651        data.install_type_info(compiled.type_info.clone());
652        let mut store = cfg.store(&engine, data).expect("store");
653        let mut linker = wasmtime::Linker::<StoreData>::new(&engine);
654        crate::runtime::install_runtime_async(&mut linker, &mut store)
655            .await
656            .expect("install");
657        let inst = linker
658            .instantiate_async(&mut store, &module)
659            .await
660            .expect("instantiate");
661        let err = crate::runtime::dispatch_main_async(&mut store, &inst)
662            .await
663            .expect_err("an unnameable principal must be refused");
664        let msg = format!("{err}");
665        assert!(
666            msg.contains("<unnamed module>"),
667            "must name the running module as the unresolvable one, not claim no frame exists: \
668             {msg}"
669        );
670        // An invariant, not a policy outcome — no rule can grant it, so the message must not
671        // send the reader off to ask the operator for one.
672        assert!(
673            !msg.contains("operator's policy"),
674            "an unresolvable principal is an invariant refusal, not a policy denial: {msg}"
675        );
676    }
677
678    /// An unnamed frame *between* the running code and its caller. `@test/inner` runs the
679    /// check and is named; the relay that invoked it is not; `main` is named beyond that.
680    ///
681    /// The walk must refuse here rather than skip the unnameable frame and name `main` — that
682    /// would attribute the call to something two hops out that never invoked the check, which
683    /// is the confused deputy the guard exists to prevent. Before the walk failed closed it
684    /// did exactly that.
685    ///
686    /// `install_package_modules_async` now refuses to link an unnamed package, so this is
687    /// unreachable in production; the modules are linked by hand to reach the guard directly.
688    #[tokio::test]
689    async fn an_unnamed_frame_between_caller_and_callee_is_refused() {
690        let (inner_bytes, inner_decl, inner_ti) = crate::codegen::tests::compile_package_modules(
691            "test:inner",
692            &[(
693                "lib",
694                r#"
695                import { check } from "submilli:security";
696
697                /**
698                 * Gates an operation on behalf of whoever called it.
699                 * @param foo Value the capability filter matches.
700                 * @capability test.com/op { foo }
701                 */
702                export function guarded(foo: number): void {
703                    check("test.com/op", { foo });
704                }
705                "#,
706            )],
707            &[],
708        );
709        let (relay_bytes, relay_decl, relay_ti) = crate::codegen::tests::compile_package_modules(
710            "test:relay",
711            &[(
712                "lib",
713                r#"
714                import { guarded } from "test:inner";
715
716                /**
717                 * Calls the inner package, standing between it and the script.
718                 * @param foo Value passed to the inner package.
719                 */
720                export function relay(foo: number): void {
721                    guarded(foo);
722                }
723                "#,
724            )],
725            &[&inner_decl],
726        );
727        // The middle frame loses its identity; the other two keep theirs.
728        let relay_bytes = strip_name_section(&relay_bytes);
729
730        let cfg = crate::runtime::RuntimeConfig::default();
731        let engine = cfg.engine().expect("engine");
732        let mut data =
733            crate::runtime::StoreData::with_vfs(crate::runtime::Vfs::tempdir().expect("tempdir"));
734        data.install_type_info(inner_ti);
735        data.install_type_info(relay_ti);
736        let mut store = cfg.store(&engine, data).expect("store");
737        let mut linker = wasmtime::Linker::<StoreData>::new(&engine);
738        crate::runtime::install_runtime_async(&mut linker, &mut store)
739            .await
740            .expect("install");
741
742        let inner_module = wasmtime::Module::new(&engine, &inner_bytes).expect("inner module");
743        let inner_inst = linker
744            .instantiate_async(&mut store, &inner_module)
745            .await
746            .expect("instantiate inner");
747        linker
748            .instance(&mut store, "test:inner", inner_inst)
749            .expect("register inner");
750
751        let relay_module = wasmtime::Module::new(&engine, &relay_bytes).expect("relay module");
752        assert_eq!(
753            relay_module.name(),
754            None,
755            "the middle module must genuinely lack a name, or the test proves nothing"
756        );
757        let relay_inst = linker
758            .instantiate_async(&mut store, &relay_module)
759            .await
760            .expect("instantiate relay");
761        linker
762            .instance(&mut store, "test:relay", relay_inst)
763            .expect("register relay");
764        let public_name = crate::mangle::package_symbol("test:relay", "relay");
765        let relay_fn = relay_inst
766            .get_func(&mut store, public_name.as_str())
767            .expect("relay export");
768        linker
769            .define(&mut store, "test:relay", "relay", relay_fn)
770            .expect("plain package import alias");
771
772        let consumer = crate::compile_script(
773            r#"
774            import { relay } from "test:relay";
775            function main(): void { relay(1); }
776            "#,
777            "consumer.subm",
778            crate::FileId(0),
779            &[&relay_decl],
780            &[],
781        )
782        .expect("consumer compiles");
783        store
784            .data_mut()
785            .install_type_info(consumer.type_info.clone());
786        let consumer_module = wasmtime::Module::new(&engine, &consumer.wasm).expect("module");
787        let inst = linker
788            .instantiate_async(&mut store, &consumer_module)
789            .await
790            .expect("instantiate consumer");
791        let err = crate::runtime::dispatch_main_async(&mut store, &inst)
792            .await
793            .expect_err("an unnameable frame in the walk must be refused");
794        let msg = format!("{err}");
795        assert!(
796            msg.contains("<unnamed module>"),
797            "must refuse on the unnameable frame: {msg}"
798        );
799        assert!(
800            !msg.contains("caller=main"),
801            "must not skip the unnameable frame and name main: {msg}"
802        );
803    }
804
805    #[test]
806    fn module_name_matches_runtime_constant() {
807        // Kept stable for any external embedders that reference the
808        // constant by re-export from `crate::runtime`.
809        assert_eq!(MODULE_NAME, "submilli:security");
810    }
811
812    struct DenyAll;
813    impl crate::runtime::SecurityCheck for DenyAll {
814        fn check(
815            &self,
816            _caller: &str,
817            _capability: &str,
818            _context: &serde_json::Value,
819        ) -> CheckOutcome {
820            CheckOutcome::Deny {
821                rule: None,
822                reason: "blocked by policy".to_string(),
823            }
824        }
825    }
826
827    #[tokio::test]
828    async fn uncaught_denial_renders_message_and_backtrace() {
829        let source = "import { check } from \"submilli:security\";\n\n\
830                      function main(): void {\n  check(\"test.com/op\", { foo: 1 });\n}\n";
831        let compiled = crate::compile_script(source, "script.subm", crate::FileId(0), &[], &[])
832            .expect("compile clean");
833        let cfg = crate::runtime::RuntimeConfig::default();
834        let engine = cfg.engine().expect("engine");
835        let mut data =
836            crate::runtime::StoreData::with_vfs(crate::runtime::Vfs::tempdir().expect("tempdir"));
837        data.install_type_info(compiled.type_info.clone());
838        data.security_check = std::sync::Arc::new(DenyAll);
839        let mut store = cfg.store(&engine, data).expect("store");
840        let module = wasmtime::Module::new(&engine, &compiled.wasm).expect("module");
841        let mut linker = wasmtime::Linker::<StoreData>::new(&engine);
842        crate::runtime::install_runtime_async(&mut linker, &mut store)
843            .await
844            .expect("install");
845        let inst = linker
846            .instantiate_async(&mut store, &module)
847            .await
848            .expect("instantiate");
849        let err = crate::runtime::dispatch_main_async(&mut store, &inst)
850            .await
851            .expect_err("uncaught denial surfaces as a runtime error");
852
853        let msg = format!("{err}");
854        assert!(
855            msg.contains(
856                "PermissionDeniedError: permission denied: \
857                 caller=main capability=test.com/op: blocked by policy"
858            ),
859            "expected the class-prefixed denial message; got: {msg}"
860        );
861        assert!(
862            msg.contains("fields:")
863                && msg.contains("capability = \"test.com/op\"")
864                && msg.contains("caller = \"main\""),
865            "denial data fields should render on the fields line; got: {msg}"
866        );
867
868        let (sources, file) = crate::Sources::single("script.subm", source).unwrap();
869        let rendered =
870            crate::backtrace::render(&err, &sources, file, crate::backtrace::BacktraceMode::Full)
871                .expect("an uncaught denial should render a backtrace");
872        assert!(
873            rendered.contains("at main ("),
874            "backtrace should point at main: {rendered}"
875        );
876        assert!(
877            rendered.contains("check(\"test.com/op\""),
878            "source context should show the check() call site: {rendered}"
879        );
880    }
881}