Skip to main content

kranz_engine/gate_evaluation/
snapshot.rs

1//! A source-only snapshot for independent evaluators. No checkout, shared Git
2//! metadata, worker transcript, provider state or authority file is mounted.
3use super::evidence::{SnapshotEntry, SnapshotInventory};
4use super::protocol::{Digest, Id, WirePath};
5use crate::git_ops::GitRepo;
6use cap_fs_ext::{DirExt, OpenOptionsFollowExt};
7use cap_primitives::fs::FollowSymlinks;
8use cap_std::fs::{Dir, OpenOptions};
9use serde::{Deserialize, Serialize};
10use std::collections::BTreeMap;
11use std::io::Read;
12
13pub const MAX_SOURCE_BYTES: usize = 128 * 1024 * 1024;
14pub const MAX_SOURCE_FILE_BYTES: u64 = 8 * 1024 * 1024;
15
16/// Conventional private/runtime paths are never source inputs. The snapshot
17/// binds this selection and lists excluded paths; stage policy must retain
18/// that coverage limitation. Selected mission scope/criteria/receipts travel
19/// separately, never by mounting the mission directory.
20pub const EXCLUDED_PREFIXES: &[&str] = &[
21    ".git",
22    ".claude",
23    ".codex",
24    ".ssh",
25    ".aws",
26    ".azure",
27    ".config",
28    ".git-credentials",
29    ".netrc",
30    ".npmrc",
31    ".pypirc",
32    ".kranz/missions",
33    ".kranz/control",
34    ".kranz/runs",
35    ".kranz/queue",
36    ".kranz/hook-status",
37    ".kranz/config.json",
38    ".kranz/serve.token",
39    ".kranz/serve.read.token",
40    ".kranz/domain-terms.local",
41];
42
43pub fn excluded(path: &str) -> bool {
44    let path = path.to_ascii_lowercase();
45    path.split('/')
46        .any(|part| part == ".env" || part.starts_with(".env."))
47        || std::iter::once(path.as_str())
48            .chain(path.match_indices('/').map(|(index, _)| &path[index + 1..]))
49            .any(|suffix| {
50                EXCLUDED_PREFIXES.iter().any(|prefix| {
51                    suffix == *prefix
52                        || suffix
53                            .strip_prefix(prefix)
54                            .is_some_and(|tail| tail.starts_with('/'))
55                })
56            })
57}
58
59fn contains_private_key(bytes: &[u8]) -> bool {
60    bytes.split(|b| *b == b'\n').any(|line| {
61        let line = line.trim_ascii();
62        line.strip_prefix(b"-----BEGIN ")
63            .and_then(|label| label.strip_suffix(b"PRIVATE KEY-----"))
64            .is_some_and(|kind| kind.iter().all(|b| b.is_ascii_uppercase() || *b == b' '))
65    })
66}
67
68#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
69#[serde(rename_all = "camelCase", deny_unknown_fields)]
70pub struct Identity {
71    pub head: String,
72    pub base: String,
73    pub inventory_digest: Digest,
74    pub exclusions_digest: Digest,
75}
76
77#[derive(Debug)]
78pub struct SourceSnapshot {
79    pub identity: Identity,
80    pub inventory: Vec<u8>,
81    /// Exact selection bytes for the future stage input builder to retain.
82    pub selection: Vec<u8>,
83    /// Source artifact IDs are derived from their complete relative path. The
84    /// inventory is the authoritative mapping back to source labels and modes.
85    pub files: BTreeMap<Id, Vec<u8>>,
86    pub excluded_paths: Vec<String>,
87}
88
89impl SourceSnapshot {
90    pub fn capture(repo: &GitRepo, base: &str) -> Result<Self, String> {
91        let repo = repo.with_hooks_disabled().map_err(|e| e.to_string())?;
92        if !repo.has_normal_index_entries().map_err(|e| e.to_string())? {
93            return Err("gate source snapshot refuses hidden index flags".into());
94        }
95        let base = repo.rev_parse(base).map_err(|e| e.to_string())?;
96        let head = repo.head_sha().map_err(|e| e.to_string())?;
97        let root = Dir::open_ambient_dir(repo.root(), cap_std::ambient_authority())
98            .map_err(|e| e.to_string())?;
99        let paths = repo.gate_snapshot_paths(&base).map_err(|e| e.to_string())?;
100        let mut entries = Vec::new();
101        let mut files = BTreeMap::new();
102        let mut total = 0usize;
103        let mut excluded_paths = Vec::new();
104        for path in paths {
105            if excluded(&path) {
106                excluded_paths.push(path);
107                continue;
108            }
109            let path = WirePath::try_from(path)?;
110            let Some((bytes, executable)) = read_source(&root, &path)? else {
111                entries.push(SnapshotEntry::Deleted { path });
112                continue;
113            };
114            total = total
115                .checked_add(bytes.len())
116                .ok_or("source size overflow")?;
117            if total > MAX_SOURCE_BYTES {
118                return Err("gate source snapshot exceeds byte limit".into());
119            }
120            if contains_private_key(&bytes)
121                || !crate::scrub::scan_text(&String::from_utf8_lossy(&bytes)).is_empty()
122            {
123                excluded_paths.push(path.as_str().to_string());
124                continue;
125            }
126            let label_digest = Digest::of(path.as_str().as_bytes());
127            let artifact_id = Id::try_from(format!("source-{}", &label_digest.as_str()[7..]))?;
128            entries.push(SnapshotEntry::File {
129                path,
130                artifact_id: artifact_id.clone(),
131                digest: Digest::of(&bytes),
132                executable,
133            });
134            files.insert(artifact_id, bytes);
135        }
136        super::protocol::validate_paths(entries.iter().map(|entry| match entry {
137            SnapshotEntry::File { path, .. } | SnapshotEntry::Deleted { path } => path.as_str(),
138        }))?;
139        if repo.head_sha().map_err(|e| e.to_string())? != head {
140            return Err("candidate HEAD moved during gate snapshot capture".into());
141        }
142        let inventory = serde_json::to_vec(&SnapshotInventory {
143            schema_version: 1,
144            entries,
145        })
146        .map_err(|e| e.to_string())?;
147        let selection = serde_json::to_vec(&serde_json::json!({
148            "prefixes": EXCLUDED_PREFIXES,
149            "prefixMatching": "directory-bounded at any depth, ASCII case-insensitive",
150            "privateKeys": "files containing a PEM private-key BEGIN line are excluded in full",
151            "detectedCredentials": "files matching the engine secret scanner are excluded in full; changed excluded source blocks acceptance",
152            "missionRecords": "only the current mission's enumerated record files and missions/index.md are metadata, supplied through approved scope and evidence rather than source coverage",
153            "environmentFiles": ".env and .env.* at any depth, ASCII case-insensitive",
154            "ignoredFiles": "Git standard excludes at capture; ignored caches are not evidence",
155            "excludedPaths": excluded_paths,
156        }))
157        .map_err(|e| e.to_string())?;
158        Ok(Self {
159            identity: Identity {
160                head,
161                base,
162                inventory_digest: Digest::of(&inventory),
163                exclusions_digest: Digest::of(&selection),
164            },
165            inventory,
166            selection,
167            files,
168            excluded_paths,
169        })
170    }
171
172    /// Acceptance judges committed source, and must not omit a changed source
173    /// path to keep credentials private. Mission records are a separately
174    /// declared metadata class (approved plan/evidence), never source coverage.
175    pub fn verify_candidate(&self, repo: &GitRepo, mission_id: &str) -> Result<(), String> {
176        let repo = repo.with_hooks_disabled().map_err(|e| e.to_string())?;
177        if !repo.is_clean_tracked_strict().map_err(|e| e.to_string())? {
178            return Err(
179                "gate acceptance requires committed candidate bytes and normal index flags".into(),
180            );
181        }
182        for path in repo.untracked_files().map_err(|e| e.to_string())? {
183            let path = path
184                .to_str()
185                .ok_or("gate acceptance refuses a non-UTF-8 untracked path")?;
186            if !crate::contract_sweep::is_mission_record_path(mission_id, path) {
187                return Err(format!(
188                    "gate acceptance requires committed candidate source; untracked path: {}",
189                    crate::presentation::visible(path)
190                ));
191            }
192        }
193        let changed = repo
194            .review_changed_paths(&self.identity.base)
195            .map_err(|e| e.to_string())?;
196        let missing: Vec<_> = self
197            .excluded_paths
198            .iter()
199            .filter(|path| {
200                changed.contains(path)
201                    && !crate::contract_sweep::is_mission_record_path(mission_id, path)
202            })
203            .collect();
204        if !missing.is_empty() {
205            return Err(format!("gate source coverage is incomplete; changed private/excluded paths require separate review: {}",
206                crate::presentation::visible(&missing.iter().map(|p| p.as_str()).collect::<Vec<_>>().join(", "))));
207        }
208        self.verify_current(&repo)
209    }
210
211    pub fn verify_current(&self, repo: &GitRepo) -> Result<(), String> {
212        let current = Self::capture(repo, &self.identity.base)?;
213        if current.identity != self.identity {
214            return Err("candidate bytes changed after gate evidence was frozen".into());
215        }
216        Ok(())
217    }
218}
219
220fn read_source(root: &Dir, path: &WirePath) -> Result<Option<(Vec<u8>, bool)>, String> {
221    let mut parent = root.try_clone().map_err(|e| e.to_string())?;
222    let mut components = path.as_str().split('/').peekable();
223    let name = loop {
224        let part = components.next().ok_or("empty source path")?;
225        if components.peek().is_none() {
226            break part;
227        }
228        parent = match parent.open_dir_nofollow(part) {
229            Ok(dir) => dir,
230            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
231            Err(_) => {
232                return Err(format!(
233                    "source parent is not a real directory: {}",
234                    path.as_str()
235                ))
236            }
237        };
238    };
239    let mut options = OpenOptions::new();
240    options.read(true).follow(FollowSymlinks::No);
241    #[cfg(unix)]
242    {
243        use cap_fs_ext::OpenOptionsExt;
244        options.custom_flags(libc::O_NONBLOCK);
245    }
246    let mut file = match parent.open_with(name, &options) {
247        Ok(file) => file.into_std(),
248        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
249        Err(_) => {
250            return Err(format!(
251                "source cannot be opened without following links: {}",
252                path.as_str()
253            ))
254        }
255    };
256    let before = file.metadata().map_err(|e| e.to_string())?;
257    if !before.is_file() || before.len() > MAX_SOURCE_FILE_BYTES {
258        return Err(format!(
259            "source requires a bounded regular file (submodules are unsupported): {}",
260            path.as_str()
261        ));
262    }
263    #[cfg(unix)]
264    {
265        use std::os::unix::fs::MetadataExt;
266        if before.nlink() != 1 {
267            return Err(format!(
268                "hard-linked source is unsupported: {}",
269                path.as_str()
270            ));
271        }
272    }
273    let mut bytes = Vec::new();
274    (&mut file)
275        .take(MAX_SOURCE_FILE_BYTES + 1)
276        .read_to_end(&mut bytes)
277        .map_err(|e| e.to_string())?;
278    let after = file.metadata().map_err(|e| e.to_string())?;
279    if bytes.len() as u64 != before.len()
280        || after.len() != before.len()
281        || after.modified().ok() != before.modified().ok()
282        || after.permissions() != before.permissions()
283    {
284        return Err(format!("source changed during capture: {}", path.as_str()));
285    }
286    #[cfg(unix)]
287    let executable = {
288        use std::os::unix::fs::PermissionsExt;
289        before.permissions().mode() & 0o111 != 0
290    };
291    #[cfg(not(unix))]
292    let executable = false;
293    Ok(Some((bytes, executable)))
294}