Skip to main content

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}