1use 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 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 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 #[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 #[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 #[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 #[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 #[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 #[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 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}