Skip to main content

celln_spec/
lib.rs

1//! Strict TOML cell specifications.
2
3use serde::{Deserialize, Serialize};
4use std::path::{Path, PathBuf};
5
6/// A cell specification.
7#[derive(Debug, Clone, Serialize, Deserialize)]
8#[serde(deny_unknown_fields)]
9pub struct Spec {
10    pub name: String,
11
12    #[serde(default)]
13    pub cell: Cell,
14
15    #[serde(default, rename = "tool")]
16    pub tools: Vec<Tool>,
17
18    #[serde(default)]
19    pub run: Option<Run>,
20}
21
22#[derive(Debug, Clone, Serialize, Deserialize)]
23#[serde(deny_unknown_fields)]
24pub struct Cell {
25    #[serde(default = "default_memory")]
26    pub memory: String,
27
28    #[serde(default = "default_tier")]
29    pub require_tier: Tier,
30}
31
32impl Default for Cell {
33    fn default() -> Self {
34        Cell {
35            memory: default_memory(),
36            require_tier: default_tier(),
37        }
38    }
39}
40
41fn default_memory() -> String {
42    "256MiB".into()
43}
44fn default_tier() -> Tier {
45    Tier::Verified
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
49#[serde(rename_all = "lowercase")]
50pub enum Tier {
51    Forged,
52    Verified,
53    Unsealed,
54}
55
56impl std::fmt::Display for Tier {
57    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
58        f.write_str(match self {
59            Tier::Forged => "forged",
60            Tier::Verified => "verified",
61            Tier::Unsealed => "unsealed",
62        })
63    }
64}
65
66/// One tool the cell may be lent.
67#[derive(Debug, Clone, Serialize, Deserialize)]
68#[serde(deny_unknown_fields)]
69pub struct Tool {
70    /// The name the agent uses, e.g. `/usr/bin/python`. A naming convenience:
71    /// authority comes from the content hash, never from this path.
72    pub alias: String,
73
74    /// Where the bytes come from on this host.
75    pub path: PathBuf,
76
77    /// True for interpreters (python, sh, node…). An interpreter fed input the
78    /// agent wrote is moved to the agent lane *for that invocation* — the
79    /// laundering ban. Getting this wrong is the most consequential mistake
80    /// available in this file, which is why `celln spec check` guesses at it and
81    /// warns when your answer disagrees.
82    #[serde(default)]
83    pub interpreter: bool,
84}
85
86/// What the agent intends to run.
87#[derive(Debug, Clone, Serialize, Deserialize)]
88#[serde(deny_unknown_fields)]
89pub struct Run {
90    /// Which tool alias to execute.
91    pub exec: String,
92    #[serde(default)]
93    pub args: Vec<String>,
94    /// Provenance of what it is being fed. `data` means the agent produced it,
95    /// which demotes an interpreter.
96    #[serde(default)]
97    pub input: Input,
98}
99
100/// Where the input to an exec came from.
101#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
102#[serde(rename_all = "lowercase")]
103pub enum Input {
104    /// Nothing interpreted (e.g. `ls`).
105    #[default]
106    None,
107    /// A file that came in through the attestation gate.
108    Tool,
109    /// A file the agent wrote. Demotes an interpreter.
110    Data,
111}
112
113/// A spec problem worth stopping for.
114#[derive(Debug, Clone, PartialEq, Eq)]
115pub struct Problem {
116    pub field: String,
117    pub message: String,
118    /// What to do about it. Every problem has one.
119    pub fix: String,
120}
121
122impl std::fmt::Display for Problem {
123    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
124        write!(f, "{}: {}\n  fix: {}", self.field, self.message, self.fix)
125    }
126}
127
128/// A non-fatal observation. Warnings never block a run.
129#[derive(Debug, Clone, PartialEq, Eq)]
130pub struct Warning {
131    pub field: String,
132    pub message: String,
133}
134
135#[derive(Debug, thiserror::Error)]
136pub enum SpecError {
137    #[error("reading {path}: {source}")]
138    Io {
139        path: PathBuf,
140        #[source]
141        source: std::io::Error,
142    },
143    #[error("{path} is not valid TOML: {source}")]
144    Syntax {
145        path: PathBuf,
146        #[source]
147        source: toml::de::Error,
148    },
149    #[error("{path} has {} problem(s)", problems.len())]
150    Invalid {
151        path: PathBuf,
152        problems: Vec<Problem>,
153    },
154}
155
156impl Spec {
157    /// Parse and validate a spec file.
158    pub fn load(path: impl AsRef<Path>) -> Result<Self, SpecError> {
159        let path = path.as_ref().to_path_buf();
160        let text = std::fs::read_to_string(&path).map_err(|source| SpecError::Io {
161            path: path.clone(),
162            source,
163        })?;
164        let spec: Spec = toml::from_str(&text).map_err(|source| SpecError::Syntax {
165            path: path.clone(),
166            source,
167        })?;
168        let problems = spec.problems();
169        if !problems.is_empty() {
170            return Err(SpecError::Invalid { path, problems });
171        }
172        Ok(spec)
173    }
174
175    /// Everything wrong with this spec that should stop a run.
176    pub fn problems(&self) -> Vec<Problem> {
177        let mut out = Vec::new();
178
179        if self.name.trim().is_empty() {
180            out.push(Problem {
181                field: "name".into(),
182                message: "is empty".into(),
183                fix: "give the cell a name, e.g. name = \"code-reviewer\"".into(),
184            });
185        }
186
187        if parse_size(&self.cell.memory).is_none() {
188            out.push(Problem {
189                field: "cell.memory".into(),
190                message: format!("{:?} is not a size", self.cell.memory),
191                fix: "use a number with a unit, e.g. \"256MiB\" or \"1GiB\"".into(),
192            });
193        }
194
195        for (i, tool) in self.tools.iter().enumerate() {
196            let at = format!("tool[{i}]");
197            if !tool.alias.starts_with('/') {
198                out.push(Problem {
199                    field: format!("{at}.alias"),
200                    message: format!("{:?} is not an absolute path", tool.alias),
201                    fix: "aliases look like paths, e.g. \"/usr/bin/python\"".into(),
202                });
203            }
204            if !tool.path.exists() {
205                out.push(Problem {
206                    field: format!("{at}.path"),
207                    message: format!("{} does not exist", tool.path.display()),
208                    fix: "point at a real file on this host; a tool is bytes, and \
209                          they have to come from somewhere"
210                        .into(),
211                });
212            }
213        }
214
215        let aliases: Vec<&str> = self.tools.iter().map(|t| t.alias.as_str()).collect();
216        for (i, tool) in self.tools.iter().enumerate() {
217            if aliases.iter().filter(|a| **a == tool.alias).count() > 1
218                && aliases.iter().position(|a| *a == tool.alias) == Some(i)
219            {
220                out.push(Problem {
221                    field: format!("tool[{i}].alias"),
222                    message: format!("{:?} is listed more than once", tool.alias),
223                    fix: "one entry per alias; the later one would silently win".into(),
224                });
225            }
226        }
227
228        if let Some(run) = &self.run {
229            if !aliases.contains(&run.exec.as_str()) {
230                out.push(Problem {
231                    field: "run.exec".into(),
232                    message: format!("{:?} is not one of the tools", run.exec),
233                    fix: format!(
234                        "add a [[tool]] with alias = {:?}, or point run.exec at one of: {}",
235                        run.exec,
236                        if aliases.is_empty() {
237                            "(none declared)".to_string()
238                        } else {
239                            aliases.join(", ")
240                        }
241                    ),
242                });
243            }
244        }
245
246        out
247    }
248
249    /// Things worth saying that should not stop a run.
250    pub fn warnings(&self) -> Vec<Warning> {
251        let mut out = Vec::new();
252
253        for tool in &self.tools {
254            // The laundering ban only fires for interpreters, so an interpreter
255            // not marked as one is a silent hole: the agent writes a script,
256            // feeds it to python, and it runs with full tool-lane authority.
257            if !tool.interpreter && looks_like_interpreter(&tool.alias) {
258                out.push(Warning {
259                    field: format!("tool {:?}", tool.alias),
260                    message: "looks like an interpreter but interpreter = false. \
261                              Anything it is fed will keep full tool-lane authority, \
262                              including code the agent wrote. Set interpreter = true \
263                              unless you mean that."
264                        .into(),
265                });
266            }
267        }
268
269        if self.tools.is_empty() {
270            out.push(Warning {
271                field: "tool".into(),
272                message: "no tools declared — the cell will be able to execute nothing".into(),
273            });
274        }
275
276        if self.cell.require_tier == Tier::Unsealed {
277            out.push(Warning {
278                field: "cell.require_tier".into(),
279                message: "unsealed means no attestation is required; the cell is still \
280                          hardware-isolated, but nothing carries tool-lane authority"
281                    .into(),
282            });
283        }
284
285        out
286    }
287
288    /// Guest memory in bytes.
289    pub fn memory_bytes(&self) -> u64 {
290        parse_size(&self.cell.memory).unwrap_or(256 << 20)
291    }
292}
293
294/// Names that are interpreters in practice. Used only to warn.
295fn looks_like_interpreter(alias: &str) -> bool {
296    let base = alias.rsplit('/').next().unwrap_or(alias);
297    let base = base.trim_end_matches(|c: char| c.is_ascii_digit() || c == '.');
298    matches!(
299        base,
300        "python"
301            | "python3"
302            | "sh"
303            | "bash"
304            | "zsh"
305            | "dash"
306            | "node"
307            | "nodejs"
308            | "ruby"
309            | "perl"
310            | "lua"
311            | "php"
312            | "deno"
313            | "bun"
314            | "awk"
315            | "tclsh"
316    )
317}
318
319/// `256MiB`, `1GiB`, `512M`, `1073741824`.
320pub fn parse_size(s: &str) -> Option<u64> {
321    let s = s.trim();
322    let split = s.find(|c: char| !c.is_ascii_digit()).unwrap_or(s.len());
323    let (num, unit) = s.split_at(split);
324    let n: u64 = num.parse().ok()?;
325    let mult = match unit.trim().to_ascii_lowercase().as_str() {
326        "" | "b" => 1,
327        "k" | "kib" | "kb" => 1 << 10,
328        "m" | "mib" | "mb" => 1 << 20,
329        "g" | "gib" | "gb" => 1 << 30,
330        _ => return None,
331    };
332    n.checked_mul(mult)
333}
334
335/// A starter spec, written to be read: every field is explained where it is.
336pub const TEMPLATE: &str = r#"# A Celln cell spec.
337#
338# This describes the cell an agent runs in: how big it is, which tools it may
339# be lent, and what it intends to run. Anything not listed here cannot execute
340# inside the cell — that is the point of the file.
341#
342#   celln spec check agent.toml   # validate, and show what would happen
343#   celln run agent.toml          # seal a cell and do it
344
345name = "my-agent"
346
347[cell]
348# Guest memory. A cell's real cost is the pages it dirties, not the size it is
349# given, so being generous here is cheap.
350memory = "256MiB"
351
352# The weakest tier a tool may be admitted at and still carry full authority:
353#   forged   — rebuilt from source and signed  (minutes, background)
354#   verified — upstream binary, pinned+scanned (seconds, the cold path)
355#   unsealed — no attestation at all           (instant, never tool-lane)
356require_tier = "verified"
357
358# Each tool is lent to the cell as sealed, read-only memory. The guest can read
359# and execute it and cannot modify it — not even as root, not even with its own
360# page tables. Revoking it stops it in every running cell.
361[[tool]]
362alias = "/usr/bin/python"      # the name the agent uses
363path = "/usr/bin/python3"      # where the bytes come from on this host
364interpreter = true             # see below
365
366# `interpreter = true` is the most consequential line in this file. An
367# interpreter fed something the agent wrote is moved to the agent lane for
368# that invocation, so `python evil.py` and `python -c "..."` do not get to
369# launder agent-authored code into full authority. Mark interpreters as
370# interpreters.
371
372[run]
373exec = "/usr/bin/python"
374args = ["review.py"]
375# Where the input came from:
376#   none — nothing interpreted
377#   tool — came in through the attestation gate
378#   data — the agent wrote it  (demotes an interpreter)
379input = "data"
380"#;
381
382#[cfg(test)]
383mod tests {
384    use super::*;
385
386    fn spec_from(s: &str) -> Spec {
387        toml::from_str(s).expect("parses")
388    }
389
390    #[test]
391    fn the_template_is_valid_toml_and_parses() {
392        let spec: Spec = toml::from_str(TEMPLATE).expect("template parses");
393        assert_eq!(spec.name, "my-agent");
394        assert_eq!(spec.tools.len(), 1);
395        assert!(spec.tools[0].interpreter);
396        assert_eq!(spec.run.unwrap().input, Input::Data);
397    }
398
399    #[test]
400    fn sizes_parse() {
401        assert_eq!(parse_size("256MiB"), Some(256 << 20));
402        assert_eq!(parse_size("1GiB"), Some(1 << 30));
403        assert_eq!(parse_size("512M"), Some(512 << 20));
404        assert_eq!(parse_size("4096"), Some(4096));
405        assert_eq!(parse_size("lots"), None);
406        assert_eq!(parse_size("12 parsecs"), None);
407    }
408
409    #[test]
410    fn a_typo_is_an_error_not_a_shrug() {
411        // `teir` silently ignored would mean a cell at the wrong trust level.
412        let err = toml::from_str::<Spec>("name = \"x\"\n[cell]\nteir = \"forged\"\n")
413            .expect_err("unknown field must be rejected");
414        assert!(err.to_string().contains("teir"), "{err}");
415    }
416
417    #[test]
418    fn missing_tool_bytes_are_a_problem_with_a_fix() {
419        let spec = spec_from(
420            "name = \"x\"\n[[tool]]\nalias = \"/usr/bin/python\"\npath = \"/nope/absent\"\n",
421        );
422        let problems = spec.problems();
423        assert_eq!(problems.len(), 1);
424        assert!(problems[0].field.contains("path"));
425        assert!(!problems[0].fix.is_empty());
426    }
427
428    #[test]
429    fn run_exec_must_name_a_declared_tool() {
430        let spec = spec_from("name = \"x\"\n[run]\nexec = \"/usr/bin/ghost\"\n");
431        let p = spec.problems();
432        assert!(p.iter().any(|p| p.field == "run.exec"), "{p:?}");
433        // and the fix lists what is actually available
434        assert!(p.iter().any(|p| p.fix.contains("none declared")));
435    }
436
437    #[test]
438    fn an_unmarked_interpreter_warns() {
439        let spec =
440            spec_from("name = \"x\"\n[[tool]]\nalias = \"/usr/bin/python3\"\npath = \"/\"\n");
441        assert!(spec
442            .warnings()
443            .iter()
444            .any(|w| w.message.contains("laundering") || w.message.contains("interpreter")));
445    }
446
447    #[test]
448    fn a_real_binary_is_not_flagged_as_an_interpreter() {
449        let spec = spec_from("name = \"x\"\n[[tool]]\nalias = \"/usr/bin/ls\"\npath = \"/\"\n");
450        assert!(!spec
451            .warnings()
452            .iter()
453            .any(|w| w.message.contains("interpreter")));
454    }
455
456    #[test]
457    fn duplicate_aliases_are_caught_once() {
458        let spec = spec_from(
459            "name = \"x\"\n\
460             [[tool]]\nalias = \"/a\"\npath = \"/\"\n\
461             [[tool]]\nalias = \"/a\"\npath = \"/\"\n",
462        );
463        let dupes: Vec<_> = spec
464            .problems()
465            .into_iter()
466            .filter(|p| p.message.contains("more than once"))
467            .collect();
468        assert_eq!(dupes.len(), 1, "reported once, not once per copy");
469    }
470}