Skip to main content

harness_loop/
seal.rs

1//! Making the gate unforgeable.
2//!
3//! [`crate::Acceptance`] asks whether the work is really done. It is worth
4//! nothing if the agent can edit the thing doing the asking — and that is not a
5//! hypothetical: the reliably-observed failure is a model that cannot make a
6//! test pass loosening the test until it does, then reporting success. The run
7//! ends green, the gate was consulted, and it agreed, because by then it was a
8//! different gate.
9//!
10//! So a check may declare the files that *define* it —
11//! [`Acceptance::seals`](crate::Acceptance::seals). Those are digested before
12//! the model gets its first turn and re-digested before any pass is accepted. A
13//! difference means the contract moved during the run, and the run fails on
14//! that ground alone, whatever the checks said.
15//!
16//! **What this enforces, precisely.** It detects that a sealed file's bytes
17//! changed between the start of the run and the verdict. That is the whole
18//! claim. It does not stop the write — the filesystem sandbox is what does
19//! that, and sealing is the check for hosts that do not have one, or for paths
20//! outside it. It also cannot see a file the check reads but did not declare;
21//! `seals()` is a promise the check makes about itself, and a check that lies
22//! about its inputs is not sealed no matter what this module does.
23//!
24//! Sealing is opt-in and empty by default, because some runs are *supposed* to
25//! rewrite the tests. Seal what must not move.
26
27use serde::{Deserialize, Serialize};
28use sha2::{Digest, Sha256};
29use std::collections::BTreeMap;
30use std::path::{Path, PathBuf};
31
32/// A sealed file's state at a point in time.
33///
34/// `None` records "absent", which has to be distinguishable from any digest:
35/// creating a contract file that was not there, or deleting one that was, are
36/// both tampering and both would otherwise be invisible.
37pub type Digest64 = Option<String>;
38
39/// The digests of every sealed path, taken together.
40#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
41pub struct SealSet {
42    /// Path → digest, ordered so the set serialises identically every time.
43    pub entries: BTreeMap<PathBuf, Digest64>,
44}
45
46/// One file that moved while the run was in flight.
47#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
48pub struct SealBreach {
49    pub path: PathBuf,
50    pub before: Digest64,
51    pub after: Digest64,
52}
53
54impl SealBreach {
55    /// Said the way a human reading a failed run needs to hear it.
56    pub fn describe(&self) -> String {
57        let what = match (&self.before, &self.after) {
58            (Some(_), Some(_)) => "was modified",
59            (None, Some(_)) => "was created",
60            (Some(_), None) => "was deleted",
61            (None, None) => "changed", // unreachable: equal states are not breaches
62        };
63        format!("{} {}", self.path.display(), what)
64    }
65}
66
67impl SealSet {
68    /// Digest every path, relative to `root` when the path is relative.
69    ///
70    /// An unreadable path is recorded as absent rather than as an error: the
71    /// question is only whether it is the same at the end as at the start, and
72    /// "could not read it either time" is a consistent answer.
73    pub fn capture<I, P>(root: &Path, paths: I) -> Self
74    where
75        I: IntoIterator<Item = P>,
76        P: AsRef<Path>,
77    {
78        let mut entries = BTreeMap::new();
79        for p in paths {
80            let rel = p.as_ref().to_path_buf();
81            let full = if rel.is_absolute() {
82                rel.clone()
83            } else {
84                root.join(&rel)
85            };
86            entries.insert(rel, digest_file(&full));
87        }
88        Self { entries }
89    }
90
91    pub fn is_empty(&self) -> bool {
92        self.entries.is_empty()
93    }
94
95    /// Every path whose digest differs from `self`.
96    ///
97    /// Compares over the union of both key sets, so a check whose declared
98    /// seals somehow differ between capture and verify still reports rather
99    /// than silently skipping the paths only one side knows about.
100    pub fn breaches(&self, now: &SealSet) -> Vec<SealBreach> {
101        let mut out = Vec::new();
102        let mut keys: Vec<&PathBuf> = self.entries.keys().chain(now.entries.keys()).collect();
103        keys.sort();
104        keys.dedup();
105        for k in keys {
106            let before = self.entries.get(k).cloned().flatten();
107            let after = now.entries.get(k).cloned().flatten();
108            if before != after {
109                out.push(SealBreach {
110                    path: k.clone(),
111                    before,
112                    after,
113                });
114            }
115        }
116        out
117    }
118}
119
120/// `sha256` of a file's bytes, hex-encoded; `None` when it cannot be read.
121fn digest_file(path: &Path) -> Digest64 {
122    let bytes = std::fs::read(path).ok()?;
123    let mut h = Sha256::new();
124    h.update(&bytes);
125    Some(format!("{:x}", h.finalize()))
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131
132    fn tmp() -> PathBuf {
133        let d = std::env::temp_dir().join(format!(
134            "harness-seal-{}-{:?}",
135            std::process::id(),
136            std::thread::current().id()
137        ));
138        std::fs::create_dir_all(&d).unwrap();
139        d
140    }
141
142    #[test]
143    fn an_untouched_contract_is_not_a_breach() {
144        let d = tmp();
145        std::fs::write(d.join("check.sh"), "exit 0").unwrap();
146        let a = SealSet::capture(&d, ["check.sh"]);
147        let b = SealSet::capture(&d, ["check.sh"]);
148        assert!(a.breaches(&b).is_empty());
149        let _ = std::fs::remove_dir_all(&d);
150    }
151
152    #[test]
153    fn loosening_the_test_is_caught() {
154        // The motivating case, written out: the check said the answer must be
155        // 42, the model could not manage it, so the model edited the check.
156        let d = tmp();
157        let f = d.join("expected.txt");
158        std::fs::write(&f, "42").unwrap();
159        let before = SealSet::capture(&d, ["expected.txt"]);
160        std::fs::write(&f, "any").unwrap();
161        let after = SealSet::capture(&d, ["expected.txt"]);
162
163        let b = before.breaches(&after);
164        assert_eq!(b.len(), 1);
165        assert_eq!(b[0].path, PathBuf::from("expected.txt"));
166        assert!(b[0].describe().contains("was modified"));
167        let _ = std::fs::remove_dir_all(&d);
168    }
169
170    #[test]
171    fn deleting_the_contract_is_a_breach_not_a_pass() {
172        // Absent must not read as "nothing to compare, therefore fine" — that
173        // would make `rm` the cheapest way through the gate.
174        let d = tmp();
175        let f = d.join("gone.txt");
176        std::fs::write(&f, "contract").unwrap();
177        let before = SealSet::capture(&d, ["gone.txt"]);
178        std::fs::remove_file(&f).unwrap();
179        let after = SealSet::capture(&d, ["gone.txt"]);
180
181        let b = before.breaches(&after);
182        assert_eq!(b.len(), 1);
183        assert!(b[0].describe().contains("was deleted"));
184        let _ = std::fs::remove_dir_all(&d);
185    }
186
187    #[test]
188    fn creating_a_contract_that_was_absent_is_a_breach() {
189        let d = tmp();
190        let before = SealSet::capture(&d, ["appears.txt"]);
191        std::fs::write(d.join("appears.txt"), "now here").unwrap();
192        let after = SealSet::capture(&d, ["appears.txt"]);
193        let b = before.breaches(&after);
194        assert_eq!(b.len(), 1);
195        assert!(b[0].describe().contains("was created"));
196        let _ = std::fs::remove_dir_all(&d);
197    }
198
199    #[test]
200    fn a_file_that_never_existed_is_not_a_breach() {
201        let d = tmp();
202        let a = SealSet::capture(&d, ["nope.txt"]);
203        let b = SealSet::capture(&d, ["nope.txt"]);
204        assert!(a.breaches(&b).is_empty(), "absent twice is consistent");
205        let _ = std::fs::remove_dir_all(&d);
206    }
207
208    #[test]
209    fn the_digest_is_of_content_not_of_the_path() {
210        // Two different files with identical bytes must digest the same, so a
211        // check that renames its contract cannot pass by shuffling paths.
212        let d = tmp();
213        std::fs::write(d.join("a.txt"), "same").unwrap();
214        std::fs::write(d.join("b.txt"), "same").unwrap();
215        let s = SealSet::capture(&d, ["a.txt", "b.txt"]);
216        let vals: Vec<_> = s.entries.values().cloned().collect();
217        assert_eq!(vals[0], vals[1]);
218        assert!(vals[0].is_some());
219        let _ = std::fs::remove_dir_all(&d);
220    }
221}