tatara_lisp_script/stdlib/profile.rs
1//! Capability profiles — what an interpreter is *able* to reach.
2//!
3//! ## The mechanism is absence, not a check
4//!
5//! A profile does not gate calls at runtime. It decides which `install`
6//! functions run at all, so a form outside the profile is never bound
7//! and calling it is an unbound-symbol error raised by the evaluator
8//! itself. There is no policy object to consult, no branch to get wrong,
9//! and nothing for a clever script to talk its way past — the same
10//! shape wasm-platform's capability mapper uses, where "capabilities not
11//! present in the set are simply not granted", and the same property
12//! blue's wasm tests assert as `imports: 0`.
13//!
14//! ## Why this exists
15//!
16//! `install_stdlib` was all-or-nothing: 26 unconditional `install()`
17//! calls. Every embedder got all 56 native functions, including
18//! `sh-exec` (a literal `sh -c` with full metacharacter interpretation),
19//! `rm-rf`, `env-set`, `kube-bearer-token` (the pod's ServiceAccount
20//! token as a string), `sops-extract`, and `dns/upsert`+`dns/delete`
21//! which mutate live DNS. That set is correct for an operator running a
22//! deployment script on a workstation. It is wrong for anything
23//! evaluating source it did not write — a controller reconciling a CR, a
24//! renderer inside a compliance boundary — and there was previously no
25//! way to say so.
26//!
27//! ## Honest limits
28//!
29//! This bounds *reach*, not *time or memory*. A sealed profile can still
30//! spin forever or allocate without limit; that is what
31//! `tatara-lisp-eval`'s `Budget` is for, and the two compose rather than
32//! substitute. And a profile says nothing about what the embedder
33//! registers itself afterwards — `install_with` is the floor, not a
34//! ceiling.
35
36use tatara_lisp_eval::Interpreter;
37
38use super::ScriptCtx;
39
40/// What a family of primitives can reach outside the process.
41///
42/// Ordered by blast radius, and deliberately coarse: a finer taxonomy
43/// invites arguments about which bucket a form belongs in, and the whole
44/// value here is that the answer is obvious.
45#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
46pub enum Capability {
47 /// Computation only. No syscall reaches outside this process.
48 Pure,
49 /// Reads the clock or the random pool. Non-deterministic, but
50 /// observes nothing it could not have been told.
51 Ambient,
52 /// Reads the filesystem.
53 FsRead,
54 /// Writes or deletes on the filesystem.
55 FsWrite,
56 /// Reads or writes process environment variables.
57 Env,
58 /// Reads host identity — hostname, username, platform.
59 HostInfo,
60 /// Opens network connections, or listens.
61 Net,
62 /// Reads Kubernetes service-account credentials.
63 ClusterCredentials,
64 /// Decrypts secrets.
65 Secrets,
66 /// Starts a subprocess.
67 Subprocess,
68 /// Loads and evaluates another module.
69 ModuleLoad,
70}
71
72impl Capability {
73 /// Does this capability reach outside the process at all?
74 ///
75 /// The gate in this crate's tests keys on exactly this, so a new
76 /// capability variant is classified once, here, rather than in every
77 /// place that asks the question.
78 #[must_use]
79 pub fn escapes_process(self) -> bool {
80 !matches!(self, Self::Pure | Self::Ambient)
81 }
82}
83
84/// One stdlib family and what it can reach.
85pub struct Family {
86 pub name: &'static str,
87 pub capability: Capability,
88 install: fn(&mut Interpreter<ScriptCtx>),
89}
90
91/// Every FFI family, with its capability.
92///
93/// This is the ENUMERABLE catalogue: a reviewer reads one list rather
94/// than 26 files, and the test below reads the same list rather than a
95/// hand-maintained copy that could drift from it.
96#[must_use]
97pub fn families() -> Vec<Family> {
98 use super::{
99 cli, crypto_extra, dns, encoding, env, fs, hash, http, http_server, io, json, kube,
100 list_ext, log, module, os, process, regex, sops, string, string_ext, time, toml, uuid,
101 yaml,
102 };
103 vec![
104 // ── pure ────────────────────────────────────────────────────
105 Family {
106 name: "cli",
107 capability: Capability::Pure,
108 install: cli::install,
109 },
110 Family {
111 name: "encoding",
112 capability: Capability::Pure,
113 install: encoding::install,
114 },
115 Family {
116 name: "hash",
117 capability: Capability::Pure,
118 install: hash::install,
119 },
120 Family {
121 name: "json",
122 capability: Capability::Pure,
123 install: json::install,
124 },
125 Family {
126 name: "list_ext",
127 capability: Capability::Pure,
128 install: list_ext::install,
129 },
130 Family {
131 name: "log",
132 capability: Capability::Pure,
133 install: log::install,
134 },
135 Family {
136 name: "regex",
137 capability: Capability::Pure,
138 install: regex::install,
139 },
140 Family {
141 name: "string",
142 capability: Capability::Pure,
143 install: string::install,
144 },
145 Family {
146 name: "string_ext",
147 capability: Capability::Pure,
148 install: string_ext::install,
149 },
150 Family {
151 name: "toml",
152 capability: Capability::Pure,
153 install: toml::install,
154 },
155 Family {
156 name: "yaml",
157 capability: Capability::Pure,
158 install: yaml::install,
159 },
160 // ── ambient nondeterminism ──────────────────────────────────
161 // Not "pure": a sealed build that must be reproducible wants
162 // these absent too, which is why they are their own tier rather
163 // than being folded in above.
164 Family {
165 name: "crypto_extra",
166 capability: Capability::Ambient,
167 install: crypto_extra::install,
168 },
169 Family {
170 name: "time",
171 capability: Capability::Ambient,
172 install: time::install,
173 },
174 Family {
175 name: "uuid",
176 capability: Capability::Ambient,
177 install: uuid::install,
178 },
179 // ── reaches outside the process ─────────────────────────────
180 Family {
181 name: "fs",
182 capability: Capability::FsWrite,
183 install: fs::install,
184 },
185 // io carries read-file AND write-file/exit; classified by its
186 // strongest member, which is the only safe direction.
187 Family {
188 name: "io",
189 capability: Capability::FsWrite,
190 install: io::install,
191 },
192 Family {
193 name: "env",
194 capability: Capability::Env,
195 install: env::install,
196 },
197 Family {
198 name: "os",
199 capability: Capability::HostInfo,
200 install: os::install,
201 },
202 Family {
203 name: "http",
204 capability: Capability::Net,
205 install: http::install,
206 },
207 Family {
208 name: "http_server",
209 capability: Capability::Net,
210 install: http_server::install,
211 },
212 Family {
213 name: "dns",
214 capability: Capability::Net,
215 install: dns::install,
216 },
217 Family {
218 name: "kube",
219 capability: Capability::ClusterCredentials,
220 install: kube::install,
221 },
222 Family {
223 name: "sops",
224 capability: Capability::Secrets,
225 install: sops::install,
226 },
227 Family {
228 name: "process",
229 capability: Capability::Subprocess,
230 install: process::install,
231 },
232 Family {
233 name: "module",
234 capability: Capability::ModuleLoad,
235 install: module::install,
236 },
237 ]
238}
239
240/// Which capabilities an interpreter is allowed to be given.
241#[derive(Debug, Clone, PartialEq, Eq)]
242pub struct Profile {
243 pub name: &'static str,
244 allowed: Vec<Capability>,
245}
246
247impl Profile {
248 /// Everything. The historical behaviour of `install_stdlib`, kept as
249 /// the explicit default so no existing embedder changes shape.
250 #[must_use]
251 pub fn ambient() -> Self {
252 Self {
253 name: "ambient",
254 allowed: vec![
255 Capability::Pure,
256 Capability::Ambient,
257 Capability::FsRead,
258 Capability::FsWrite,
259 Capability::Env,
260 Capability::HostInfo,
261 Capability::Net,
262 Capability::ClusterCredentials,
263 Capability::Secrets,
264 Capability::Subprocess,
265 Capability::ModuleLoad,
266 ],
267 }
268 }
269
270 /// Computation only — nothing that reaches outside the process, and
271 /// nothing that observes the clock or the random pool.
272 ///
273 /// This is the profile for evaluating source you did not write.
274 #[must_use]
275 pub fn sealed() -> Self {
276 Self {
277 name: "sealed",
278 allowed: vec![Capability::Pure],
279 }
280 }
281
282 /// Sealed, plus clock and randomness. For a renderer that may stamp
283 /// a timestamp but must not reach the filesystem or the network.
284 #[must_use]
285 pub fn sealed_nondeterministic() -> Self {
286 Self {
287 name: "sealed-nondeterministic",
288 allowed: vec![Capability::Pure, Capability::Ambient],
289 }
290 }
291
292 #[must_use]
293 pub fn allows(&self, c: Capability) -> bool {
294 self.allowed.contains(&c)
295 }
296
297 /// The families this profile installs, in catalogue order.
298 #[must_use]
299 pub fn granted(&self) -> Vec<&'static str> {
300 families()
301 .into_iter()
302 .filter(|f| self.allows(f.capability))
303 .map(|f| f.name)
304 .collect()
305 }
306
307 /// The families this profile withholds.
308 #[must_use]
309 pub fn withheld(&self) -> Vec<&'static str> {
310 families()
311 .into_iter()
312 .filter(|f| !self.allows(f.capability))
313 .map(|f| f.name)
314 .collect()
315 }
316}
317
318impl Default for Profile {
319 fn default() -> Self {
320 Self::ambient()
321 }
322}
323
324/// Install the FFI families a profile permits. Families outside it are
325/// never installed, so their names are simply unbound.
326pub fn install_families(interp: &mut Interpreter<ScriptCtx>, profile: &Profile) {
327 for f in families() {
328 if profile.allows(f.capability) {
329 (f.install)(interp);
330 }
331 }
332}
333
334#[cfg(test)]
335mod tests {
336 use super::*;
337
338 /// The catalogue must cover every stdlib family, or a profile silently
339 /// stops constraining the one that was left out.
340 ///
341 /// Counted rather than named: a count carries its own denominator, so
342 /// a catalogue that stopped discovering families fails here instead of
343 /// passing vacuously with an empty list.
344 #[test]
345 fn the_catalogue_covers_every_ffi_family() {
346 let names: Vec<&str> = families().iter().map(|f| f.name).collect();
347 assert_eq!(
348 names.len(),
349 25,
350 "catalogue has {} families: {names:?}",
351 names.len()
352 );
353 for expected in [
354 "fs",
355 "io",
356 "env",
357 "os",
358 "process",
359 "http",
360 "http_server",
361 "dns",
362 "kube",
363 "sops",
364 "module",
365 ] {
366 assert!(
367 names.contains(&expected),
368 "{expected} missing from the catalogue"
369 );
370 }
371 }
372
373 /// THE GATE. A sealed profile must grant nothing that reaches outside
374 /// the process.
375 ///
376 /// Carries its denominator deliberately: asserting only "no dangerous
377 /// family is granted" would pass just as happily against an empty
378 /// catalogue, which is the failure mode that makes a security gate
379 /// worthless. So the count of families actually examined is asserted
380 /// too.
381 #[test]
382 fn a_sealed_profile_grants_nothing_that_escapes_the_process() {
383 let sealed = Profile::sealed();
384 let all = families();
385 assert!(
386 !all.is_empty(),
387 "empty catalogue — the gate would be vacuous"
388 );
389
390 let mut examined = 0;
391 for f in &all {
392 examined += 1;
393 if f.capability.escapes_process() {
394 assert!(
395 !sealed.allows(f.capability),
396 "sealed profile grants {} ({:?}), which reaches outside the process",
397 f.name,
398 f.capability
399 );
400 }
401 }
402 assert_eq!(examined, 25, "examined {examined} families, expected 25");
403
404 // And the named worst offenders are specifically withheld.
405 let withheld = sealed.withheld();
406 for dangerous in ["process", "fs", "io", "env", "kube", "sops", "http", "dns"] {
407 assert!(
408 withheld.contains(&dangerous),
409 "sealed profile must withhold {dangerous}; withheld = {withheld:?}"
410 );
411 }
412 }
413
414 #[test]
415 fn the_ambient_profile_grants_everything_so_no_existing_embedder_changes() {
416 let ambient = Profile::ambient();
417 assert!(
418 ambient.withheld().is_empty(),
419 "ambient must withhold nothing, withheld = {:?}",
420 ambient.withheld()
421 );
422 assert_eq!(ambient.granted().len(), families().len());
423 }
424
425 #[test]
426 fn sealed_nondeterministic_adds_only_the_clock_and_the_random_pool() {
427 let p = Profile::sealed_nondeterministic();
428 assert!(p.granted().contains(&"time"));
429 assert!(p.granted().contains(&"uuid"));
430 // …and still nothing that escapes.
431 for f in families() {
432 if f.capability.escapes_process() {
433 assert!(!p.allows(f.capability), "{} leaked", f.name);
434 }
435 }
436 }
437}