Skip to main content

kranz_engine/pack/
evaluator.rs

1//! Explicit schema-5 registration. The approved Git object and OCI digest,
2//! not a worker-controlled directory, supply every checker/runtime byte.
3use 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/// Can only be constructed by resolving a trusted ref. Caller authority over
170/// the approved ref is a host precondition; this is not an approval API.
171#[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    /// Resolve the configured evaluator set from the engine-pinned base,
179    /// including checker dependencies. A worker's pack edits cannot register,
180    /// remove or replace the checks used by this mission.
181    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            // Legacy local advisory packs remain supported; an untracked
213            // external evaluator must never disappear into that fallback.
214            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}