submilli-engine 0.4.3

Compiles Submilli programs, a TypeScript subset, to WebAssembly and runs them under capability policy
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
//! `submilli:secrets` — policy-gated access to Blueprint-declared secrets.
//!
//! One async Rust host function registered directly under the package name;
//! the linker resolves the user import (`submilli:secrets#get`) with no Wasm
//! module.

use crate::runtime::host::{
    abi_arg, fatal_host_error, read_string_arg, register_host_value_async, throw_host_error,
    write_submilli_string_struct,
};
use crate::runtime::{effects, fuel};
use wasmtime::{FuncType, HeapType, Linker, RefType, Val, ValType};

use crate::runtime::StoreData;
use crate::runtime::intrinsic_types::build_intrinsic_types;
use crate::stdlib::shared::check_security;
use crate::{PackageDeclaration, Param, Span, Type, ValueKind, ValueSymbol};

pub const MODULE_NAME: &str = "submilli:secrets";

pub fn package_declaration() -> PackageDeclaration {
    let mut defs = PackageDeclaration::with_package(MODULE_NAME);
    defs.values.insert(
        "get".to_string(),
        ValueSymbol {
            name: "get".to_string(),
            mangled_name: crate::mangle::package_symbol(MODULE_NAME, "get"),
            declaration_span: Span::at(crate::FileId::SECRETS),
            kind: ValueKind::Function {
                generics: Vec::new(),
                params: vec![Param::new("secret", Type::String)],
                ret: Type::union(vec![Type::String, Type::Undefined]),
                type_predicate: None,
                doc: crate::doc(
                    crate::FileId::SECRETS,
                    "/**\n * Resolve a Blueprint-declared secret by name. **Packages only** — a call from main-module code is refused whatever the policy says, because secret values must never reach it. From `main`, pass the secret *name* to the Package API that needs the credential; the Package resolves it internally and never returns it. Returns `undefined` when the secret is undeclared or unavailable. Traps if policy denies access or the resolver fails internally.\n * @param secret Secret name from the Blueprint `secrets:` block.\n * @capability secrets.get { name: $secret }\n */",
                ),
            },
        },
    );
    defs
}

pub fn install(linker: &mut Linker<StoreData>) -> wasmtime::Result<()> {
    let engine = linker.engine().clone();
    let intr = build_intrinsic_types(&engine)?;
    let string = ValType::Ref(RefType::new(false, HeapType::ConcreteStruct(intr.string)));
    // `string | undefined` lowers to the erased `(ref null $Object)` in codegen.
    let nullable_object = ValType::Ref(RefType::new(true, HeapType::ConcreteStruct(intr.object)));
    let ty = FuncType::new(&engine, [string], [nullable_object]);
    register_host_value_async(
        linker,
        MODULE_NAME,
        crate::mangle::package_symbol(MODULE_NAME, "get"),
        ty,
        |caller, params| {
            Box::pin(async move {
                let name =
                    read_string_arg(&mut *caller, abi_arg(params, 0)?, "secrets.get (secret)")?;
                check_security(
                    &mut *caller,
                    "secrets.get",
                    serde_json::json!({ "name": name }),
                )?;

                let observation = resolve_secret(caller, name).await?;
                let Some(value) = &observation else {
                    return crate::runtime::prelude::undefined::value(caller);
                };

                let st = write_submilli_string_struct(caller, value)?;
                Ok(Val::AnyRef(Some(st.to_anyref())))
            })
        },
    )
}

async fn resolve_secret(
    caller: &mut wasmtime::Caller<'_, StoreData>,
    name: String,
) -> wasmtime::Result<Option<String>> {
    let provider = caller.data().secret_provider.clone();
    if caller.data().effects.is_none() {
        return provider.get(&name).await.map_err(|message| {
            throw_host_error(
                caller,
                wasmtime::Error::msg(format!("secrets.get {name}: {message}")),
            )
        });
    }

    let encrypted = effects::execute(
        caller,
        "secrets.getEncrypted",
        name.clone(),
        |caller, _, name| {
            Box::pin(async move {
                let secret = caller
                    .data()
                    .secret_provider
                    .get_encrypted(&name)
                    .await
                    .map_err(fatal_host_error)?;
                if let Some(secret) = &secret {
                    fuel::settle(&mut *caller, fuel::HASH, secret.ciphertext.len() as u64)?;
                }
                Ok(secret)
            })
        },
    )
    .await?;
    let Some(encrypted) = encrypted else {
        return Ok(None);
    };
    // Decryption can be retried from this saved envelope without another lookup.
    fuel::charge(&mut *caller, fuel::HASH, encrypted.ciphertext.len() as u64)?;
    provider
        .decrypt(&name, &encrypted)
        .map(Some)
        .map_err(fatal_host_error)
}

#[cfg(test)]
mod tests {
    use std::collections::BTreeMap;
    use std::future::Future;
    use std::pin::Pin;
    use std::sync::{Arc, Mutex};

    use crate::compile_script;
    use crate::runtime::security::{CheckOutcome, SecurityCheck};
    use crate::runtime::{
        RuntimeConfig, SecretProvider, StoreData, Vfs, dispatch_main_async, install_runtime_async,
    };

    struct MapSecrets {
        values: BTreeMap<String, String>,
        lookups: Mutex<Vec<String>>,
    }

    impl MapSecrets {
        fn with(values: BTreeMap<String, String>) -> Arc<Self> {
            Arc::new(Self {
                values,
                lookups: Mutex::new(Vec::new()),
            })
        }

        fn one(name: &str, value: &str) -> Arc<Self> {
            Self::with(BTreeMap::from([(name.to_string(), value.to_string())]))
        }

        fn lookups(&self) -> Vec<String> {
            self.lookups.lock().expect("lookups mutex").clone()
        }
    }

    impl SecretProvider for MapSecrets {
        fn get<'a>(
            &'a self,
            name: &'a str,
        ) -> Pin<Box<dyn Future<Output = Result<Option<String>, String>> + Send + 'a>> {
            self.lookups
                .lock()
                .expect("lookups mutex")
                .push(name.to_string());
            Box::pin(async move { Ok(self.values.get(name).cloned()) })
        }
    }

    #[derive(Default)]
    struct RecordingCheck {
        seen: Mutex<Vec<(String, String, serde_json::Value)>>,
        deny: bool,
    }

    impl SecurityCheck for RecordingCheck {
        fn check(
            &self,
            caller: &str,
            capability: &str,
            context: &serde_json::Value,
        ) -> CheckOutcome {
            self.seen.lock().unwrap().push((
                caller.to_string(),
                capability.to_string(),
                context.clone(),
            ));
            if self.deny {
                CheckOutcome::Deny {
                    rule: None,
                    reason: "denied in test".to_string(),
                }
            } else {
                CheckOutcome::Allow { rule: None }
            }
        }
    }

    /// R1's core claim: nothing about the refusal depends on a policy existing.
    /// The recorder proves the refusal ran ahead of the policy delegation, and
    /// the provider proves no plaintext value was ever materialized.
    #[tokio::test]
    async fn main_is_denied_before_the_policy_and_before_any_lookup() {
        let source = r#"
            import { get } from "submilli:secrets";

            function main(): void {
                const _ = get("TOKEN");
            }
        "#;
        let provider = MapSecrets::one("TOKEN", "tok-123");
        let recording = Arc::new(RecordingCheck::default());

        let err = run_with(source, provider.clone(), recording.clone())
            .await
            .expect_err("main must be denied");

        assert!(
            format!("{err:#}").contains("permission denied: caller=main capability=secrets.get"),
            "unexpected error: {err:#}"
        );
        assert!(
            recording.seen.lock().expect("recording mutex").is_empty(),
            "the policy must never have been consulted"
        );
        assert!(
            provider.lookups().is_empty(),
            "no plaintext value may be resolved for a denied call"
        );
    }

    /// Under the default `AllowAllCheck` — the embedder that configured nothing
    /// at all. R1's claim is that this case is denied too.
    #[tokio::test]
    async fn main_is_denied_under_the_default_allow_all_check() {
        let provider = MapSecrets::one("TOKEN", "tok-123");
        let err = run_as(None, MAIN_READS_TOKEN, provider.clone(), None)
            .await
            .expect_err("main must be denied");

        assert!(
            format!("{err:#}").contains("permission denied: caller=main capability=secrets.get"),
            "unexpected error: {err:#}"
        );
        assert!(provider.lookups().is_empty(), "no lookup for a denied call");
    }

    /// Today `main` can tell a declared secret from an undeclared one by
    /// whether it gets a value or `undefined`. The refusal closes that oracle: both
    /// names must produce the identical message.
    #[tokio::test]
    async fn the_denial_is_identical_for_declared_and_undeclared_names() {
        let declared = run_as(
            None,
            MAIN_READS_TOKEN,
            MapSecrets::one("TOKEN", "tok-123"),
            None,
        )
        .await
        .expect_err("denied");
        let undeclared = run_as(
            None,
            MAIN_READS_TOKEN,
            MapSecrets::with(BTreeMap::new()),
            None,
        )
        .await
        .expect_err("denied");

        assert_eq!(format!("{declared:#}"), format!("{undeclared:#}"));
    }

    /// The wording is fixed, and each clause is load-bearing. It states that no
    /// policy can grant this, so a model does not go asking for a rule change.
    /// It names the fix *and* forecloses the misreading — routing through a
    /// package is a working escalation today, and the audience is adversarial,
    /// so "resolves it internally and never returns it" and the hand-it-back
    /// clause are both required. It keeps the do-not-work-around force, which
    /// exists because a model once hit a denial and rerouted through raw HTTP
    /// (llm-prompt.md, 2026-07-08). It names no package: listing who does
    /// hold the grant would hand `main` a map of the policy keeping it out.
    #[tokio::test]
    async fn the_denial_matches_the_fixed_wording() {
        let err = run_as(
            None,
            MAIN_READS_TOKEN,
            MapSecrets::one("TOKEN", "tok-123"),
            None,
        )
        .await
        .expect_err("denied");

        let message = format!("{err:#}");
        let (wording, fields) = message.split_once("\n").expect("fields line follows");
        assert_eq!(
            wording,
            "PermissionDeniedError: permission denied: caller=main \
             capability=secrets.get: secret values are never available to \
             main-module code, and no policy can grant this. The package that \
             needs this credential resolves it internally and never returns it \
             — pass the secret NAME to that package's API instead. Do not work \
             around this denial: not through another package, not by asking a \
             package to fetch the value and hand it back, not through raw HTTP. \
             Report it and stop.",
        );
        assert!(fields.contains("capability = \"secrets.get\""), "{fields}");
    }

    /// R2: a package reading a secret is unchanged. Keeps both the found and
    /// the absent branch, and the context recorded for each.
    #[tokio::test]
    async fn a_package_caller_still_reads_secrets_and_gets_undefined_for_absent_ones() {
        let source = r#"
            import { get } from "submilli:secrets";

            function main(): void {
                const token = get("TOKEN");
                assert(token !== undefined, "TOKEN should resolve");
                if (token !== undefined) {
                    assert(token === "tok-123", "TOKEN value");
                }

                const absent = get("ABSENT");
                assert(absent === undefined, "missing secret is undefined");
            }
        "#;
        let recording = Arc::new(RecordingCheck::default());
        run_as(
            Some(PACKAGE),
            source,
            MapSecrets::one("TOKEN", "tok-123"),
            Some(recording.clone()),
        )
        .await
        .expect("program succeeds");

        let seen = recording.seen.lock().expect("recording mutex");
        assert_eq!(seen.len(), 2);
        assert_eq!(seen[0].0, PACKAGE);
        assert_eq!(seen[0].1, "secrets.get");
        assert_eq!(seen[0].2, serde_json::json!({ "name": "TOKEN" }));
        assert_eq!(seen[1].2, serde_json::json!({ "name": "ABSENT" }));
    }

    /// R2 again: a policy denial reaching a package is worded exactly as
    /// before. Only `main` gets the new message.
    #[tokio::test]
    async fn a_package_caller_denied_by_policy_gets_the_unchanged_wording() {
        let denying = Arc::new(RecordingCheck {
            seen: Mutex::new(Vec::new()),
            deny: true,
        });
        let err = run_as(
            Some(PACKAGE),
            MAIN_READS_TOKEN,
            MapSecrets::one("TOKEN", "tok-123"),
            Some(denying),
        )
        .await
        .expect_err("denial should throw");

        let message = format!("{err:#}");
        let (wording, fields) = message.split_once("\n").expect("fields line follows");
        assert_eq!(
            wording,
            format!(
                "PermissionDeniedError: permission denied: caller={PACKAGE} \
                 capability=secrets.get: denied in test. \
                 This request is forbidden by the operator's policy — do not work around \
                 the denial (another package, raw HTTP, or other arguments to reach the same \
                 thing); report it and stop."
            ),
        );
        assert!(
            fields.contains(&format!("caller = \"{PACKAGE}\"")),
            "{fields}"
        );
    }

    const PACKAGE: &str = "@pied-piper/sdk";

    const MAIN_READS_TOKEN: &str = r#"
        import { get } from "submilli:secrets";

        function main(): void {
            const _ = get("TOKEN");
        }
    "#;

    async fn run_with(
        source: &str,
        provider: Arc<dyn SecretProvider>,
        security_check: Arc<dyn SecurityCheck>,
    ) -> wasmtime::Result<Option<String>> {
        run_as(None, source, provider, Some(security_check)).await
    }

    /// Runs `source`, optionally as code a package owns. `None` for `security_check` leaves
    /// the store's default `AllowAllCheck` in place.
    ///
    /// A package caller is produced by compiling the module under that package's name, so the
    /// identity rides the wasm frame the runtime actually reads. Declaring it out-of-band
    /// would test the harness rather than the mechanism.
    async fn run_as(
        caller: Option<&str>,
        source: &str,
        provider: Arc<dyn SecretProvider>,
        security_check: Option<Arc<dyn SecurityCheck>>,
    ) -> wasmtime::Result<Option<String>> {
        let compiled = match caller {
            Some(package) => crate::compile::compile_script_owned_by(
                package,
                source,
                "test.subm",
                crate::FileId(0),
                &[],
                &[],
            ),
            None => compile_script(source, "test.subm", crate::FileId(0), &[], &[]),
        }
        .expect("compile clean");
        let cfg = RuntimeConfig::default();
        let engine = cfg.engine().expect("engine");
        let mut data = StoreData::with_vfs("test-run".into(), Vfs::tempdir().expect("tempdir"));
        data.secret_provider = provider;
        if let Some(check) = security_check {
            data.security_check = check;
        }
        let mut store = cfg.store_async(&engine, data).expect("store");
        let module = wasmtime::Module::new(&engine, &compiled.wasm).expect("module");
        let mut linker = wasmtime::Linker::<StoreData>::new(&engine);
        install_runtime_async(&mut linker, &mut store)
            .await
            .expect("install");
        let inst = linker
            .instantiate_async(&mut store, &module)
            .await
            .expect("instantiate");
        dispatch_main_async(&mut store, &inst).await
    }
}