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}