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