Skip to main content

submilli_engine/stdlib/
secrets.rs

1//! `submilli:secrets` — policy-gated access to Blueprint-declared secrets.
2//!
3//! One async Rust host function registered directly under the package name;
4//! the linker resolves the user import (`submilli:secrets#get`) with no Wasm
5//! module.
6
7use crate::runtime::host::{
8    abi_arg, fatal_host_error, read_string_arg, register_host_value_async, throw_host_error,
9    write_submilli_string_struct,
10};
11use crate::runtime::{effects, fuel};
12use wasmtime::{FuncType, HeapType, Linker, RefType, Val, ValType};
13
14use crate::runtime::StoreData;
15use crate::runtime::intrinsic_types::build_intrinsic_types;
16use crate::stdlib::shared::check_security;
17use crate::{PackageDeclaration, Param, Span, Type, ValueKind, ValueSymbol};
18
19pub const MODULE_NAME: &str = "submilli:secrets";
20
21pub fn package_declaration() -> PackageDeclaration {
22    let mut defs = PackageDeclaration::with_package(MODULE_NAME);
23    defs.values.insert(
24        "get".to_string(),
25        ValueSymbol {
26            name: "get".to_string(),
27            mangled_name: crate::mangle::package_symbol(MODULE_NAME, "get"),
28            declaration_span: Span::at(crate::FileId::SECRETS),
29            kind: ValueKind::Function {
30                generics: Vec::new(),
31                params: vec![Param::new("secret", Type::String)],
32                ret: Type::union(vec![Type::String, Type::Undefined]),
33                type_predicate: None,
34                doc: crate::doc(
35                    crate::FileId::SECRETS,
36                    "/**\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 */",
37                ),
38            },
39        },
40    );
41    defs
42}
43
44pub fn install(linker: &mut Linker<StoreData>) -> wasmtime::Result<()> {
45    let engine = linker.engine().clone();
46    let intr = build_intrinsic_types(&engine)?;
47    let string = ValType::Ref(RefType::new(false, HeapType::ConcreteStruct(intr.string)));
48    // `string | undefined` lowers to the erased `(ref null $Object)` in codegen.
49    let nullable_object = ValType::Ref(RefType::new(true, HeapType::ConcreteStruct(intr.object)));
50    let ty = FuncType::new(&engine, [string], [nullable_object]);
51    register_host_value_async(
52        linker,
53        MODULE_NAME,
54        crate::mangle::package_symbol(MODULE_NAME, "get"),
55        ty,
56        |caller, params| {
57            Box::pin(async move {
58                let name =
59                    read_string_arg(&mut *caller, abi_arg(params, 0)?, "secrets.get (secret)")?;
60                check_security(
61                    &mut *caller,
62                    "secrets.get",
63                    serde_json::json!({ "name": name }),
64                )?;
65
66                let observation = resolve_secret(caller, name).await?;
67                let Some(value) = &observation else {
68                    return crate::runtime::prelude::undefined::value(caller);
69                };
70
71                let st = write_submilli_string_struct(caller, value)?;
72                Ok(Val::AnyRef(Some(st.to_anyref())))
73            })
74        },
75    )
76}
77
78async fn resolve_secret(
79    caller: &mut wasmtime::Caller<'_, StoreData>,
80    name: String,
81) -> wasmtime::Result<Option<String>> {
82    let provider = caller.data().secret_provider.clone();
83    if caller.data().effects.is_none() {
84        return provider.get(&name).await.map_err(|message| {
85            throw_host_error(
86                caller,
87                wasmtime::Error::msg(format!("secrets.get {name}: {message}")),
88            )
89        });
90    }
91
92    let encrypted = effects::execute(
93        caller,
94        "secrets.getEncrypted",
95        name.clone(),
96        |caller, _, name| {
97            Box::pin(async move {
98                let secret = caller
99                    .data()
100                    .secret_provider
101                    .get_encrypted(&name)
102                    .await
103                    .map_err(fatal_host_error)?;
104                if let Some(secret) = &secret {
105                    fuel::settle(&mut *caller, fuel::HASH, secret.ciphertext.len() as u64)?;
106                }
107                Ok(secret)
108            })
109        },
110    )
111    .await?;
112    let Some(encrypted) = encrypted else {
113        return Ok(None);
114    };
115    // Decryption can be retried from this saved envelope without another lookup.
116    fuel::charge(&mut *caller, fuel::HASH, encrypted.ciphertext.len() as u64)?;
117    provider
118        .decrypt(&name, &encrypted)
119        .map(Some)
120        .map_err(fatal_host_error)
121}
122
123#[cfg(test)]
124mod tests {
125    use std::collections::BTreeMap;
126    use std::future::Future;
127    use std::pin::Pin;
128    use std::sync::{Arc, Mutex};
129
130    use crate::compile_script;
131    use crate::runtime::security::{CheckOutcome, SecurityCheck};
132    use crate::runtime::{
133        RuntimeConfig, SecretProvider, StoreData, Vfs, dispatch_main_async, install_runtime_async,
134    };
135
136    struct MapSecrets {
137        values: BTreeMap<String, String>,
138        lookups: Mutex<Vec<String>>,
139    }
140
141    impl MapSecrets {
142        fn with(values: BTreeMap<String, String>) -> Arc<Self> {
143            Arc::new(Self {
144                values,
145                lookups: Mutex::new(Vec::new()),
146            })
147        }
148
149        fn one(name: &str, value: &str) -> Arc<Self> {
150            Self::with(BTreeMap::from([(name.to_string(), value.to_string())]))
151        }
152
153        fn lookups(&self) -> Vec<String> {
154            self.lookups.lock().expect("lookups mutex").clone()
155        }
156    }
157
158    impl SecretProvider for MapSecrets {
159        fn get<'a>(
160            &'a self,
161            name: &'a str,
162        ) -> Pin<Box<dyn Future<Output = Result<Option<String>, String>> + Send + 'a>> {
163            self.lookups
164                .lock()
165                .expect("lookups mutex")
166                .push(name.to_string());
167            Box::pin(async move { Ok(self.values.get(name).cloned()) })
168        }
169    }
170
171    #[derive(Default)]
172    struct RecordingCheck {
173        seen: Mutex<Vec<(String, String, serde_json::Value)>>,
174        deny: bool,
175    }
176
177    impl SecurityCheck for RecordingCheck {
178        fn check(
179            &self,
180            caller: &str,
181            capability: &str,
182            context: &serde_json::Value,
183        ) -> CheckOutcome {
184            self.seen.lock().unwrap().push((
185                caller.to_string(),
186                capability.to_string(),
187                context.clone(),
188            ));
189            if self.deny {
190                CheckOutcome::Deny {
191                    rule: None,
192                    reason: "denied in test".to_string(),
193                }
194            } else {
195                CheckOutcome::Allow { rule: None }
196            }
197        }
198    }
199
200    /// R1's core claim: nothing about the refusal depends on a policy existing.
201    /// The recorder proves the refusal ran ahead of the policy delegation, and
202    /// the provider proves no plaintext value was ever materialized.
203    #[tokio::test]
204    async fn main_is_denied_before_the_policy_and_before_any_lookup() {
205        let source = r#"
206            import { get } from "submilli:secrets";
207
208            function main(): void {
209                const _ = get("TOKEN");
210            }
211        "#;
212        let provider = MapSecrets::one("TOKEN", "tok-123");
213        let recording = Arc::new(RecordingCheck::default());
214
215        let err = run_with(source, provider.clone(), recording.clone())
216            .await
217            .expect_err("main must be denied");
218
219        assert!(
220            format!("{err:#}").contains("permission denied: caller=main capability=secrets.get"),
221            "unexpected error: {err:#}"
222        );
223        assert!(
224            recording.seen.lock().expect("recording mutex").is_empty(),
225            "the policy must never have been consulted"
226        );
227        assert!(
228            provider.lookups().is_empty(),
229            "no plaintext value may be resolved for a denied call"
230        );
231    }
232
233    /// Under the default `AllowAllCheck` — the embedder that configured nothing
234    /// at all. R1's claim is that this case is denied too.
235    #[tokio::test]
236    async fn main_is_denied_under_the_default_allow_all_check() {
237        let provider = MapSecrets::one("TOKEN", "tok-123");
238        let err = run_as(None, MAIN_READS_TOKEN, provider.clone(), None)
239            .await
240            .expect_err("main must be denied");
241
242        assert!(
243            format!("{err:#}").contains("permission denied: caller=main capability=secrets.get"),
244            "unexpected error: {err:#}"
245        );
246        assert!(provider.lookups().is_empty(), "no lookup for a denied call");
247    }
248
249    /// Today `main` can tell a declared secret from an undeclared one by
250    /// whether it gets a value or `undefined`. The refusal closes that oracle: both
251    /// names must produce the identical message.
252    #[tokio::test]
253    async fn the_denial_is_identical_for_declared_and_undeclared_names() {
254        let declared = run_as(
255            None,
256            MAIN_READS_TOKEN,
257            MapSecrets::one("TOKEN", "tok-123"),
258            None,
259        )
260        .await
261        .expect_err("denied");
262        let undeclared = run_as(
263            None,
264            MAIN_READS_TOKEN,
265            MapSecrets::with(BTreeMap::new()),
266            None,
267        )
268        .await
269        .expect_err("denied");
270
271        assert_eq!(format!("{declared:#}"), format!("{undeclared:#}"));
272    }
273
274    /// The wording is fixed, and each clause is load-bearing. It states that no
275    /// policy can grant this, so a model does not go asking for a rule change.
276    /// It names the fix *and* forecloses the misreading — routing through a
277    /// package is a working escalation today, and the audience is adversarial,
278    /// so "resolves it internally and never returns it" and the hand-it-back
279    /// clause are both required. It keeps the do-not-work-around force, which
280    /// exists because a model once hit a denial and rerouted through raw HTTP
281    /// (llm-prompt.md, 2026-07-08). It names no package: listing who does
282    /// hold the grant would hand `main` a map of the policy keeping it out.
283    #[tokio::test]
284    async fn the_denial_matches_the_fixed_wording() {
285        let err = run_as(
286            None,
287            MAIN_READS_TOKEN,
288            MapSecrets::one("TOKEN", "tok-123"),
289            None,
290        )
291        .await
292        .expect_err("denied");
293
294        let message = format!("{err:#}");
295        let (wording, fields) = message.split_once("\n").expect("fields line follows");
296        assert_eq!(
297            wording,
298            "PermissionDeniedError: permission denied: caller=main \
299             capability=secrets.get: secret values are never available to \
300             main-module code, and no policy can grant this. The package that \
301             needs this credential resolves it internally and never returns it \
302             — pass the secret NAME to that package's API instead. Do not work \
303             around this denial: not through another package, not by asking a \
304             package to fetch the value and hand it back, not through raw HTTP. \
305             Report it and stop.",
306        );
307        assert!(fields.contains("capability = \"secrets.get\""), "{fields}");
308    }
309
310    /// R2: a package reading a secret is unchanged. Keeps both the found and
311    /// the absent branch, and the context recorded for each.
312    #[tokio::test]
313    async fn a_package_caller_still_reads_secrets_and_gets_undefined_for_absent_ones() {
314        let source = r#"
315            import { get } from "submilli:secrets";
316
317            function main(): void {
318                const token = get("TOKEN");
319                assert(token !== undefined, "TOKEN should resolve");
320                if (token !== undefined) {
321                    assert(token === "tok-123", "TOKEN value");
322                }
323
324                const absent = get("ABSENT");
325                assert(absent === undefined, "missing secret is undefined");
326            }
327        "#;
328        let recording = Arc::new(RecordingCheck::default());
329        run_as(
330            Some(PACKAGE),
331            source,
332            MapSecrets::one("TOKEN", "tok-123"),
333            Some(recording.clone()),
334        )
335        .await
336        .expect("program succeeds");
337
338        let seen = recording.seen.lock().expect("recording mutex");
339        assert_eq!(seen.len(), 2);
340        assert_eq!(seen[0].0, PACKAGE);
341        assert_eq!(seen[0].1, "secrets.get");
342        assert_eq!(seen[0].2, serde_json::json!({ "name": "TOKEN" }));
343        assert_eq!(seen[1].2, serde_json::json!({ "name": "ABSENT" }));
344    }
345
346    /// R2 again: a policy denial reaching a package is worded exactly as
347    /// before. Only `main` gets the new message.
348    #[tokio::test]
349    async fn a_package_caller_denied_by_policy_gets_the_unchanged_wording() {
350        let denying = Arc::new(RecordingCheck {
351            seen: Mutex::new(Vec::new()),
352            deny: true,
353        });
354        let err = run_as(
355            Some(PACKAGE),
356            MAIN_READS_TOKEN,
357            MapSecrets::one("TOKEN", "tok-123"),
358            Some(denying),
359        )
360        .await
361        .expect_err("denial should throw");
362
363        let message = format!("{err:#}");
364        let (wording, fields) = message.split_once("\n").expect("fields line follows");
365        assert_eq!(
366            wording,
367            format!(
368                "PermissionDeniedError: permission denied: caller={PACKAGE} \
369                 capability=secrets.get: denied in test. \
370                 This request is forbidden by the operator's policy — do not work around \
371                 the denial (another package, raw HTTP, or other arguments to reach the same \
372                 thing); report it and stop."
373            ),
374        );
375        assert!(
376            fields.contains(&format!("caller = \"{PACKAGE}\"")),
377            "{fields}"
378        );
379    }
380
381    const PACKAGE: &str = "@pied-piper/sdk";
382
383    const MAIN_READS_TOKEN: &str = r#"
384        import { get } from "submilli:secrets";
385
386        function main(): void {
387            const _ = get("TOKEN");
388        }
389    "#;
390
391    async fn run_with(
392        source: &str,
393        provider: Arc<dyn SecretProvider>,
394        security_check: Arc<dyn SecurityCheck>,
395    ) -> wasmtime::Result<Option<String>> {
396        run_as(None, source, provider, Some(security_check)).await
397    }
398
399    /// Runs `source`, optionally as code a package owns. `None` for `security_check` leaves
400    /// the store's default `AllowAllCheck` in place.
401    ///
402    /// A package caller is produced by compiling the module under that package's name, so the
403    /// identity rides the wasm frame the runtime actually reads. Declaring it out-of-band
404    /// would test the harness rather than the mechanism.
405    async fn run_as(
406        caller: Option<&str>,
407        source: &str,
408        provider: Arc<dyn SecretProvider>,
409        security_check: Option<Arc<dyn SecurityCheck>>,
410    ) -> wasmtime::Result<Option<String>> {
411        let compiled = match caller {
412            Some(package) => crate::compile::compile_script_owned_by(
413                package,
414                source,
415                "test.subm",
416                crate::FileId(0),
417                &[],
418                &[],
419            ),
420            None => compile_script(source, "test.subm", crate::FileId(0), &[], &[]),
421        }
422        .expect("compile clean");
423        let cfg = RuntimeConfig::default();
424        let engine = cfg.engine().expect("engine");
425        let mut data = StoreData::with_vfs("test-run".into(), Vfs::tempdir().expect("tempdir"));
426        data.secret_provider = provider;
427        if let Some(check) = security_check {
428            data.security_check = check;
429        }
430        let mut store = cfg.store_async(&engine, data).expect("store");
431        let module = wasmtime::Module::new(&engine, &compiled.wasm).expect("module");
432        let mut linker = wasmtime::Linker::<StoreData>::new(&engine);
433        install_runtime_async(&mut linker, &mut store)
434            .await
435            .expect("install");
436        let inst = linker
437            .instantiate_async(&mut store, &module)
438            .await
439            .expect("instantiate");
440        dispatch_main_async(&mut store, &inst).await
441    }
442}