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 { name: "cli", capability: Capability::Pure, install: cli::install },
106        Family { name: "encoding", capability: Capability::Pure, install: encoding::install },
107        Family { name: "hash", capability: Capability::Pure, install: hash::install },
108        Family { name: "json", capability: Capability::Pure, install: json::install },
109        Family { name: "list_ext", capability: Capability::Pure, install: list_ext::install },
110        Family { name: "log", capability: Capability::Pure, install: log::install },
111        Family { name: "regex", capability: Capability::Pure, install: regex::install },
112        Family { name: "string", capability: Capability::Pure, install: string::install },
113        Family { name: "string_ext", capability: Capability::Pure, install: string_ext::install },
114        Family { name: "toml", capability: Capability::Pure, install: toml::install },
115        Family { name: "yaml", capability: Capability::Pure, install: yaml::install },
116        // ── ambient nondeterminism ──────────────────────────────────
117        // Not "pure": a sealed build that must be reproducible wants
118        // these absent too, which is why they are their own tier rather
119        // than being folded in above.
120        Family { name: "crypto_extra", capability: Capability::Ambient, install: crypto_extra::install },
121        Family { name: "time", capability: Capability::Ambient, install: time::install },
122        Family { name: "uuid", capability: Capability::Ambient, install: uuid::install },
123        // ── reaches outside the process ─────────────────────────────
124        Family { name: "fs", capability: Capability::FsWrite, install: fs::install },
125        // io carries read-file AND write-file/exit; classified by its
126        // strongest member, which is the only safe direction.
127        Family { name: "io", capability: Capability::FsWrite, install: io::install },
128        Family { name: "env", capability: Capability::Env, install: env::install },
129        Family { name: "os", capability: Capability::HostInfo, install: os::install },
130        Family { name: "http", capability: Capability::Net, install: http::install },
131        Family { name: "http_server", capability: Capability::Net, install: http_server::install },
132        Family { name: "dns", capability: Capability::Net, install: dns::install },
133        Family { name: "kube", capability: Capability::ClusterCredentials, install: kube::install },
134        Family { name: "sops", capability: Capability::Secrets, install: sops::install },
135        Family { name: "process", capability: Capability::Subprocess, install: process::install },
136        Family { name: "module", capability: Capability::ModuleLoad, install: module::install },
137    ]
138}
139
140/// Which capabilities an interpreter is allowed to be given.
141#[derive(Debug, Clone, PartialEq, Eq)]
142pub struct Profile {
143    pub name: &'static str,
144    allowed: Vec<Capability>,
145}
146
147impl Profile {
148    /// Everything. The historical behaviour of `install_stdlib`, kept as
149    /// the explicit default so no existing embedder changes shape.
150    #[must_use]
151    pub fn ambient() -> Self {
152        Self {
153            name: "ambient",
154            allowed: vec![
155                Capability::Pure,
156                Capability::Ambient,
157                Capability::FsRead,
158                Capability::FsWrite,
159                Capability::Env,
160                Capability::HostInfo,
161                Capability::Net,
162                Capability::ClusterCredentials,
163                Capability::Secrets,
164                Capability::Subprocess,
165                Capability::ModuleLoad,
166            ],
167        }
168    }
169
170    /// Computation only — nothing that reaches outside the process, and
171    /// nothing that observes the clock or the random pool.
172    ///
173    /// This is the profile for evaluating source you did not write.
174    #[must_use]
175    pub fn sealed() -> Self {
176        Self {
177            name: "sealed",
178            allowed: vec![Capability::Pure],
179        }
180    }
181
182    /// Sealed, plus clock and randomness. For a renderer that may stamp
183    /// a timestamp but must not reach the filesystem or the network.
184    #[must_use]
185    pub fn sealed_nondeterministic() -> Self {
186        Self {
187            name: "sealed-nondeterministic",
188            allowed: vec![Capability::Pure, Capability::Ambient],
189        }
190    }
191
192    #[must_use]
193    pub fn allows(&self, c: Capability) -> bool {
194        self.allowed.contains(&c)
195    }
196
197    /// The families this profile installs, in catalogue order.
198    #[must_use]
199    pub fn granted(&self) -> Vec<&'static str> {
200        families()
201            .into_iter()
202            .filter(|f| self.allows(f.capability))
203            .map(|f| f.name)
204            .collect()
205    }
206
207    /// The families this profile withholds.
208    #[must_use]
209    pub fn withheld(&self) -> Vec<&'static str> {
210        families()
211            .into_iter()
212            .filter(|f| !self.allows(f.capability))
213            .map(|f| f.name)
214            .collect()
215    }
216}
217
218impl Default for Profile {
219    fn default() -> Self {
220        Self::ambient()
221    }
222}
223
224/// Install the FFI families a profile permits. Families outside it are
225/// never installed, so their names are simply unbound.
226pub fn install_families(interp: &mut Interpreter<ScriptCtx>, profile: &Profile) {
227    for f in families() {
228        if profile.allows(f.capability) {
229            (f.install)(interp);
230        }
231    }
232}
233
234#[cfg(test)]
235mod tests {
236    use super::*;
237
238    /// The catalogue must cover every stdlib family, or a profile silently
239    /// stops constraining the one that was left out.
240    ///
241    /// Counted rather than named: a count carries its own denominator, so
242    /// a catalogue that stopped discovering families fails here instead of
243    /// passing vacuously with an empty list.
244    #[test]
245    fn the_catalogue_covers_every_ffi_family() {
246        let names: Vec<&str> = families().iter().map(|f| f.name).collect();
247        assert_eq!(
248            names.len(),
249            25,
250            "catalogue has {} families: {names:?}",
251            names.len()
252        );
253        for expected in [
254            "fs", "io", "env", "os", "process", "http", "http_server", "dns", "kube", "sops",
255            "module",
256        ] {
257            assert!(names.contains(&expected), "{expected} missing from the catalogue");
258        }
259    }
260
261    /// THE GATE. A sealed profile must grant nothing that reaches outside
262    /// the process.
263    ///
264    /// Carries its denominator deliberately: asserting only "no dangerous
265    /// family is granted" would pass just as happily against an empty
266    /// catalogue, which is the failure mode that makes a security gate
267    /// worthless. So the count of families actually examined is asserted
268    /// too.
269    #[test]
270    fn a_sealed_profile_grants_nothing_that_escapes_the_process() {
271        let sealed = Profile::sealed();
272        let all = families();
273        assert!(!all.is_empty(), "empty catalogue — the gate would be vacuous");
274
275        let mut examined = 0;
276        for f in &all {
277            examined += 1;
278            if f.capability.escapes_process() {
279                assert!(
280                    !sealed.allows(f.capability),
281                    "sealed profile grants {} ({:?}), which reaches outside the process",
282                    f.name,
283                    f.capability
284                );
285            }
286        }
287        assert_eq!(examined, 25, "examined {examined} families, expected 25");
288
289        // And the named worst offenders are specifically withheld.
290        let withheld = sealed.withheld();
291        for dangerous in ["process", "fs", "io", "env", "kube", "sops", "http", "dns"] {
292            assert!(
293                withheld.contains(&dangerous),
294                "sealed profile must withhold {dangerous}; withheld = {withheld:?}"
295            );
296        }
297    }
298
299    #[test]
300    fn the_ambient_profile_grants_everything_so_no_existing_embedder_changes() {
301        let ambient = Profile::ambient();
302        assert!(
303            ambient.withheld().is_empty(),
304            "ambient must withhold nothing, withheld = {:?}",
305            ambient.withheld()
306        );
307        assert_eq!(ambient.granted().len(), families().len());
308    }
309
310    #[test]
311    fn sealed_nondeterministic_adds_only_the_clock_and_the_random_pool() {
312        let p = Profile::sealed_nondeterministic();
313        assert!(p.granted().contains(&"time"));
314        assert!(p.granted().contains(&"uuid"));
315        // …and still nothing that escapes.
316        for f in families() {
317            if f.capability.escapes_process() {
318                assert!(!p.allows(f.capability), "{} leaked", f.name);
319            }
320        }
321    }
322}