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