1use serde::{Deserialize, Serialize};
4use std::path::{Path, PathBuf};
5
6#[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#[derive(Debug, Clone, Serialize, Deserialize)]
68#[serde(deny_unknown_fields)]
69pub struct Tool {
70 pub alias: String,
73
74 pub path: PathBuf,
76
77 #[serde(default)]
83 pub interpreter: bool,
84}
85
86#[derive(Debug, Clone, Serialize, Deserialize)]
88#[serde(deny_unknown_fields)]
89pub struct Run {
90 pub exec: String,
92 #[serde(default)]
93 pub args: Vec<String>,
94 #[serde(default)]
97 pub input: Input,
98}
99
100#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
102#[serde(rename_all = "lowercase")]
103pub enum Input {
104 #[default]
106 None,
107 Tool,
109 Data,
111}
112
113#[derive(Debug, Clone, PartialEq, Eq)]
115pub struct Problem {
116 pub field: String,
117 pub message: String,
118 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#[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 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 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 pub fn warnings(&self) -> Vec<Warning> {
251 let mut out = Vec::new();
252
253 for tool in &self.tools {
254 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 pub fn memory_bytes(&self) -> u64 {
290 parse_size(&self.cell.memory).unwrap_or(256 << 20)
291 }
292}
293
294fn 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
319pub 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
335pub 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 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 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}