1use crate::gate_evaluation::protocol::{ArtifactRole, Digest, Id, Stage, WirePath};
4use crate::git_ops::GitRepo;
5use serde::{Deserialize, Serialize};
6use std::collections::BTreeSet;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
9#[serde(rename_all = "kebab-case")]
10pub enum Kind {
11 Mechanical,
12 Judgment,
13}
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(rename_all = "kebab-case")]
16pub enum Enforcement {
17 Advisory,
18 Blocking,
19}
20
21#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(rename_all = "camelCase", deny_unknown_fields)]
23pub struct Declaration {
24 pub name: Id,
25 pub image: String,
26 pub executable: String,
27 pub args: Vec<String>,
28 pub files: Vec<WirePath>,
29 pub stages: Vec<Stage>,
30 pub evidence: Vec<ArtifactRole>,
31 pub kind: Kind,
32 pub enforcement: Enforcement,
33}
34
35pub(super) fn declarations(
36 doc: &super::toml::Document,
37 schema: u32,
38) -> Result<Vec<Declaration>, String> {
39 if doc.single("evaluator").is_some() {
40 return Err("evaluator must use [[evaluator]] array syntax".into());
41 }
42 let tables = doc.array("evaluator");
43 if !tables.is_empty() && schema != super::SCHEMA_EVALUATORS {
44 return Err("[[evaluator]] requires pack schema 5".into());
45 }
46 let mut declarations = Vec::new();
47 for (i, table) in tables.iter().enumerate() {
48 let label = super::entry_label("evaluator", i, table);
49 super::check_unknown(
50 table,
51 &label,
52 &[
53 "name",
54 "image",
55 "executable",
56 "args",
57 "files",
58 "stages",
59 "evidence",
60 "kind",
61 "enforcement",
62 ],
63 )?;
64 let string = |key: &str| super::required_string(table, &label, key);
65 let array = |key: &str| super::optional_string_array(table, &label, key);
66 let image = string("image")?;
67 let (repository, digest) = image
68 .split_once("@sha256:")
69 .ok_or("evaluator image requires an OCI sha256 digest pin")?;
70 if repository.is_empty()
71 || repository.starts_with('-')
72 || !repository
73 .bytes()
74 .all(|b| b.is_ascii_alphanumeric() || b"/._:-".contains(&b))
75 {
76 return Err("invalid evaluator image repository".into());
77 }
78 Digest::try_from(format!("sha256:{digest}"))?;
79 let executable = string("executable")?;
80 WirePath::try_from(
81 executable
82 .strip_prefix('/')
83 .ok_or("evaluator executable must be absolute inside the image")?
84 .to_owned(),
85 )?;
86 let args = array("args")?;
87 if args.len() > 128 || args.iter().any(|s| s.len() > 8192 || s.contains('\0')) {
88 return Err("evaluator argv exceeds limits or contains NUL".into());
89 }
90 let files = array("files")?
91 .into_iter()
92 .map(WirePath::try_from)
93 .collect::<Result<Vec<_>, _>>()?;
94 let parse = |key: &str| -> Result<Vec<serde_json::Value>, String> {
95 Ok(array(key)?
96 .into_iter()
97 .map(serde_json::Value::String)
98 .collect())
99 };
100 let stages: Vec<Stage> = serde_json::from_value(serde_json::Value::Array(parse("stages")?))
101 .map_err(|_| "unsupported evaluator stage")?;
102 let evidence: Vec<ArtifactRole> =
103 serde_json::from_value(serde_json::Value::Array(parse("evidence")?))
104 .map_err(|_| "unsupported evaluator evidence role")?;
105 let kind = serde_json::from_value(serde_json::Value::String(string("kind")?))
106 .map_err(|_| "evaluator kind must be mechanical or judgment")?;
107 let enforcement = serde_json::from_value(serde_json::Value::String(string("enforcement")?))
108 .map_err(|_| "evaluator enforcement must be advisory or blocking")?;
109 let name = Id::try_from(string("name")?)?;
110 if super::RESERVED_GATE_NAMES.contains(&name.as_str()) {
111 return Err("evaluator name is reserved by the engine".into());
112 }
113 if files.is_empty()
114 || files.len() > 128
115 || stages.is_empty()
116 || stages.len() > 5
117 || evidence.len() > 16
118 {
119 return Err("evaluator requires bounded files and supported stages".into());
120 }
121 crate::gate_evaluation::protocol::validate_paths(files.iter().map(|p| p.as_str()))?;
122 if files.iter().any(|p| {
123 p.as_str()
124 .split('/')
125 .next()
126 .is_some_and(|p| p.eq_ignore_ascii_case("engine-mount-proof"))
127 }) {
128 return Err("checker uses a reserved engine proof path".into());
129 }
130 let paths: BTreeSet<_> = files
131 .iter()
132 .map(|p| p.as_str().to_ascii_lowercase())
133 .collect();
134 if paths.len() != files.len()
135 || stages
136 .iter()
137 .enumerate()
138 .any(|(i, s)| stages[..i].contains(s))
139 || evidence
140 .iter()
141 .enumerate()
142 .any(|(i, e)| evidence[..i].contains(e))
143 {
144 return Err("duplicate evaluator files/stages/evidence".into());
145 }
146 declarations.push(Declaration {
147 name,
148 image,
149 executable,
150 args,
151 files,
152 stages,
153 evidence,
154 kind,
155 enforcement,
156 });
157 }
158 super::reject_duplicate_names("evaluator", declarations.iter().map(|d| d.name.as_str()))?;
159 Ok(declarations)
160}
161
162#[derive(Debug, Clone)]
163pub(crate) struct CheckerFile {
164 pub path: WirePath,
165 pub bytes: Vec<u8>,
166 pub executable: bool,
167}
168
169#[derive(Debug, Clone)]
172pub struct PinnedRegistration {
173 pub(crate) declaration: Declaration,
174 pub(crate) files: Vec<CheckerFile>,
175 pub(crate) bytes: Vec<u8>,
176}
177impl PinnedRegistration {
178 pub(crate) fn configured_at_ref(
182 repo: &GitRepo,
183 config: &crate::types::MissionConfig,
184 approved_ref: &str,
185 ) -> Result<Vec<Self>, String> {
186 let Some(directory) = config.pack_dir.as_deref() else {
187 return Ok(vec![]);
188 };
189 if std::path::Path::new(directory).is_absolute()
190 || directory.split('/').any(|part| part == "..")
191 {
192 let pack = super::load_for_config(config, repo.root())?;
193 if pack.is_some_and(|p| !p.evaluators.is_empty()) {
194 return Err(
195 "external evaluators must be vendored in a repo-relative packDir".into(),
196 );
197 }
198 return Ok(vec![]);
199 }
200 let directory = directory.trim_end_matches('/');
201 let directory = if directory == "." { "" } else { directory };
202 if !directory.is_empty() {
203 super::validate_pack_relative_path(directory, "approved pack", "directory")?;
204 }
205 let path = if directory.is_empty() {
206 super::PACK_MANIFEST.to_string()
207 } else {
208 format!("{directory}/{}", super::PACK_MANIFEST)
209 };
210 let oid = repo.rev_parse(approved_ref).map_err(|e| e.to_string())?;
211 let Some(bytes) = repo.show_file(&oid, &path).map_err(|e| e.to_string())? else {
212 if super::load_for_config(config, repo.root())?
215 .is_some_and(|p| !p.evaluators.is_empty())
216 {
217 return Err("external evaluator pack is absent from the approved base".into());
218 }
219 return Ok(vec![]);
220 };
221 let text = std::str::from_utf8(&bytes).map_err(|_| "approved pack is not UTF-8")?;
222 let doc = super::toml::parse(text)?;
223 super::validate_sections(&doc)?;
224 let (_, schema) = super::manifest_header(&doc)?;
225 let declarations = declarations(&doc, schema)?;
226 if declarations.len() > 32 {
227 return Err("too many mission evaluators".into());
228 }
229 let mut pinned = declarations
230 .iter()
231 .map(|d| Self::at_ref(repo, &oid, directory, d.name.as_str()))
232 .collect::<Result<Vec<_>, _>>()?;
233 pinned.sort_by_key(|p| p.declaration.kind == Kind::Judgment);
234 Ok(pinned)
235 }
236 pub fn checker_files(&self) -> impl Iterator<Item = (&WirePath, &[u8], bool)> {
237 self.files
238 .iter()
239 .map(|f| (&f.path, f.bytes.as_slice(), f.executable))
240 }
241 pub fn declaration(&self) -> &Declaration {
242 &self.declaration
243 }
244 pub fn bytes(&self) -> &[u8] {
245 &self.bytes
246 }
247 pub fn digest(&self) -> Digest {
248 Digest::of(&self.bytes)
249 }
250
251 pub fn at_ref(
252 repo: &GitRepo,
253 approved_ref: &str,
254 pack_dir: &str,
255 name: &str,
256 ) -> Result<Self, String> {
257 if !pack_dir.is_empty() {
258 super::validate_pack_relative_path(pack_dir, "approved pack", "directory")?;
259 }
260 let join = |path: &str| {
261 if pack_dir.is_empty() {
262 path.to_string()
263 } else {
264 format!("{pack_dir}/{path}")
265 }
266 };
267 let oid = repo.rev_parse(approved_ref).map_err(|e| e.to_string())?;
268 let read = |path: &str| -> Result<(Vec<u8>, bool), String> {
269 let entries = repo
270 .ls_tree_recursive(&oid, path)
271 .map_err(|e| e.to_string())?;
272 let entry = entries
273 .iter()
274 .find(|entry| entry.path == path)
275 .ok_or_else(|| format!("approved checker file missing: {path}"))?;
276 if entry.kind != "blob"
277 || !["100644", "100755"].contains(&entry.mode.as_str())
278 || entry.size.is_none_or(|s| s > 8 * 1024 * 1024)
279 {
280 return Err(format!(
281 "checker requires a bounded regular Git blob: {path}"
282 ));
283 }
284 let bytes = repo
285 .show_file(&oid, path)
286 .map_err(|e| e.to_string())?
287 .ok_or("checker blob disappeared")?;
288 if bytes.len() as u64 != entry.size.unwrap_or(0) {
289 return Err("checker blob size mismatch".into());
290 }
291 Ok((bytes, entry.mode == "100755"))
292 };
293 let (manifest, _) = read(&join(super::PACK_MANIFEST))?;
294 let text = std::str::from_utf8(&manifest).map_err(|_| "pack manifest is not UTF-8")?;
295 let doc = super::toml::parse(text)?;
296 super::validate_sections(&doc)?;
297 let (_, schema) = super::manifest_header(&doc)?;
298 let declaration = declarations(&doc, schema)?
299 .into_iter()
300 .find(|d| d.name.as_str() == name)
301 .ok_or("evaluator is not registered at the approved ref")?;
302 let mut files = Vec::new();
303 let mut inventory = Vec::new();
304 let mut total = 0usize;
305 for path in &declaration.files {
306 let (bytes, executable) = read(&join(path.as_str()))?;
307 total += bytes.len();
308 if total > 32 * 1024 * 1024 {
309 return Err("checker dependency bytes exceed limit".into());
310 }
311 inventory.push(serde_json::json!({"path":path,"digest":Digest::of(&bytes),"bytes":bytes.len(),"executable":executable}));
312 files.push(CheckerFile {
313 path: path.clone(),
314 bytes,
315 executable,
316 });
317 }
318 let bytes = serde_json::to_vec(&serde_json::json!({"schemaVersion":1,"sourceCommit":oid,"packDirectory":pack_dir,"manifestDigest":Digest::of(&manifest),"declaration":declaration,"files":inventory})).map_err(|e| e.to_string())?;
319 Ok(Self {
320 declaration,
321 files,
322 bytes,
323 })
324 }
325}