1use 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: Default::default(),
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 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 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 #[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 #[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 #[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 #[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 #[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 #[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 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}