Skip to main content

speccoder_runtime/
integrity.rs

1//! The integrity audit of an attempt's diff.
2//!
3//! Line-based and deliberately suspicious: it errs on flagging. Any finding
4//! fails the verdict with an `integrity:` reason; a person looks.
5
6use std::collections::{BTreeMap, BTreeSet};
7use std::path::Path;
8
9use regex::Regex;
10use sha2::{Digest, Sha256};
11
12use crate::tools::paths::{Class, Protected};
13use crate::tools::policy::Policy;
14use crate::worktree::{Change, ChangeKind};
15
16/// Directories a snapshot never descends into: build output and installed
17/// dependencies, where no judge input lives.
18const SKIPPED_DIRS: &[&str] = &[
19    ".git",
20    "target",
21    "node_modules",
22    ".venv",
23    "venv",
24    "__pycache__",
25    ".tox",
26    ".mypy_cache",
27    ".pytest_cache",
28];
29
30/// The judge, intent and agent context inputs of a worktree by content hash,
31/// git-ignored files included (`.claude/settings.local.json` is ignored by
32/// default, and an agent still loads it).
33#[derive(Debug, Clone, Default, PartialEq, Eq)]
34pub struct JudgeSnapshot(BTreeMap<String, String>);
35
36/// A judge input that changed between two snapshots.
37#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
38pub struct Drift {
39    /// Workspace-relative path.
40    pub path: String,
41    /// `judge`, `intent` or `context`.
42    pub class: &'static str,
43    /// `added`, `modified` or `deleted`.
44    pub change: &'static str,
45}
46
47impl JudgeSnapshot {
48    /// Hashes every judge, intent and context input under `root` as `policy`
49    /// sees them (a planning skill may write Spec Kit's artifacts).
50    #[must_use]
51    pub fn take(root: &Path, policy: &Policy) -> Self {
52        let mut files = BTreeMap::new();
53        let walk = ignore::WalkBuilder::new(root)
54            .hidden(false)
55            .ignore(false)
56            .git_ignore(false)
57            .git_global(false)
58            .git_exclude(false)
59            .parents(false)
60            .follow_links(false)
61            .filter_entry(|e| {
62                !(e.depth() > 0
63                    && e.file_type().is_some_and(|t| t.is_dir())
64                    && SKIPPED_DIRS.contains(&e.file_name().to_string_lossy().as_ref()))
65            })
66            .build();
67        for entry in walk.flatten() {
68            if !entry.file_type().is_some_and(|t| t.is_file()) {
69                continue;
70            }
71            let Ok(rel) = entry.path().strip_prefix(root) else {
72                continue;
73            };
74            let rel = rel.to_string_lossy().replace('\\', "/");
75            if class_of(policy, &rel).is_none() {
76                continue;
77            }
78            if let Ok(bytes) = std::fs::read(entry.path()) {
79                files.insert(rel, sha256_hex(&bytes));
80            }
81        }
82        Self(files)
83    }
84
85    /// Path → content hash.
86    #[must_use]
87    pub fn entries(&self) -> &BTreeMap<String, String> {
88        &self.0
89    }
90
91    /// A snapshot from recorded entries.
92    #[must_use]
93    pub fn from_entries(entries: BTreeMap<String, String>) -> Self {
94        Self(entries)
95    }
96
97    /// Records `path`'s new content hash (the engine's own sanctioned write).
98    pub fn set(&mut self, path: &str, hash: String) {
99        self.0.insert(path.to_owned(), hash);
100    }
101
102    /// What changed from `self` to `later`, except `approved` paths (a
103    /// person's grant to write them).
104    #[must_use]
105    pub fn drift(&self, later: &Self, policy: &Policy, approved: &BTreeSet<String>) -> Vec<Drift> {
106        let mut out = Vec::new();
107        let paths: BTreeSet<&String> = self.0.keys().chain(later.0.keys()).collect();
108        for path in paths {
109            let change = match (self.0.get(path), later.0.get(path)) {
110                (None, Some(_)) => "added",
111                (Some(_), None) => "deleted",
112                (Some(a), Some(b)) if a != b => "modified",
113                _ => continue,
114            };
115            if approved.contains(path.as_str()) {
116                continue;
117            }
118            if let Some(class) = class_of(policy, path) {
119                out.push(Drift {
120                    path: path.clone(),
121                    class,
122                    change,
123                });
124            }
125        }
126        out
127    }
128}
129
130fn class_of(policy: &Policy, rel: &str) -> Option<&'static str> {
131    match policy.write_protected(rel) {
132        Some(Class::Judge) => Some("judge"),
133        Some(Class::Intent) => Some("intent"),
134        Some(_) => None,
135        None if policy.protected().is_context(rel) => Some("context"),
136        None => None,
137    }
138}
139
140/// Markers that declare a test.
141const TEST_MARKERS: &str = r"#\[(tokio::)?test\b|^\s*def test_|^\s*(async\s+)?fn test_|\b(it|test)\s*\(\s*['\x22`]|@Test\b|^\s*func Test";
142/// Markers that skip a test.
143const SKIP_MARKERS: &str = r"#\[ignore\b|@pytest\.mark\.(skip|xfail)|\.skip\s*\(|\bx(it|describe|test)\s*\(|@Disabled\b|@Ignore\b|\bt\.Skip(Now)?\s*\(|@unittest\.skip|\.todo\s*\(";
144
145/// Whether every line of `old` survives, unchanged and in order, in `new`:
146/// the change only adds lines (`[tests] frozen = "additions"`).
147#[must_use]
148pub fn only_additions(old: &str, new: &str) -> bool {
149    let mut new_lines = new.lines();
150    old.lines().all(|line| new_lines.any(|n| n == line))
151}
152
153/// Findings for an attempt: `changes` and `patch` between its checkpoints,
154/// `frozen` the test files that existed when it started. With
155/// `additions_ok`, a frozen test whose diff only adds lines is no finding
156/// (removed tests and skip markers still are). An agent context file
157/// (`AGENTS.md`, …) changed without a person's grant in `approved` is one.
158#[must_use]
159pub fn audit(
160    changes: &[Change],
161    patch: &str,
162    frozen: &BTreeSet<String>,
163    additions_ok: bool,
164    approved: &BTreeSet<String>,
165) -> Vec<String> {
166    audit_in(
167        &Protected::default(),
168        changes,
169        patch,
170        frozen,
171        additions_ok,
172        approved,
173    )
174}
175
176/// [`audit`] against the protected set of the project's format.
177#[must_use]
178pub fn audit_in(
179    protected: &Protected,
180    changes: &[Change],
181    patch: &str,
182    frozen: &BTreeSet<String>,
183    additions_ok: bool,
184    approved: &BTreeSet<String>,
185) -> Vec<String> {
186    let mut findings = Vec::new();
187    let removed_lines = removed_lines(patch);
188
189    for c in changes {
190        if frozen.contains(&c.path) {
191            match c.kind {
192                ChangeKind::Deleted => findings.push(format!("frozen test deleted: {}", c.path)),
193                ChangeKind::Modified
194                    if additions_ok && removed_lines.get(&c.path).copied().unwrap_or(0) == 0 => {}
195                ChangeKind::Modified => findings.push(format!("frozen test modified: {}", c.path)),
196                ChangeKind::Added => {}
197            }
198        }
199        match protected.write_class(&c.path) {
200            Some(Class::Judge) => findings.push(format!("verification input changed: {}", c.path)),
201            Some(Class::Intent) => findings.push(format!("spec changed: {}", c.path)),
202            None if protected.is_context(&c.path) && !approved.contains(&c.path) => {
203                findings.push(format!("agent context file changed: {}", c.path));
204            }
205            _ => {}
206        }
207    }
208
209    let (Ok(test), Ok(skip)) = (Regex::new(TEST_MARKERS), Regex::new(SKIP_MARKERS)) else {
210        return findings;
211    };
212    let mut tests: BTreeMap<String, (usize, usize)> = BTreeMap::new(); // (added, removed)
213    let mut skips: BTreeSet<String> = BTreeSet::new();
214    let mut file = String::new();
215    for line in patch.lines() {
216        if let Some(header) = line.strip_prefix("diff --git a/") {
217            // `diff --git a/<path> b/<path>` (no renames): the path is the
218            // same on both sides, also for additions and deletions.
219            file = header
220                .split_once(" b/")
221                .map_or(header, |(a, _)| a)
222                .to_owned();
223        } else if line.starts_with("+++ ") || line.starts_with("--- ") {
224            // File headers; the name came from `diff --git`.
225        } else if let Some(added) = line.strip_prefix('+') {
226            if test.is_match(added) {
227                tests.entry(file.clone()).or_default().0 += 1;
228            }
229            if skip.is_match(added) {
230                skips.insert(file.clone());
231            }
232        } else if let Some(removed) = line.strip_prefix('-')
233            && test.is_match(removed)
234        {
235            tests.entry(file.clone()).or_default().1 += 1;
236        }
237    }
238    let deleted: BTreeSet<&str> = changes
239        .iter()
240        .filter(|c| c.kind == ChangeKind::Deleted)
241        .map(|c| c.path.as_str())
242        .collect();
243    for (path, (added, removed)) in tests {
244        // A deleted file is reported as such (frozen) or is new work undone.
245        if removed > added && !deleted.contains(path.as_str()) {
246            findings.push(format!(
247                "test removed in {path} ({removed} removed, {added} added)"
248            ));
249        }
250    }
251    for path in skips {
252        findings.push(format!("skip marker added in {path}"));
253    }
254    findings
255}
256
257/// Removed lines per file in a unified diff.
258fn removed_lines(patch: &str) -> BTreeMap<String, usize> {
259    let mut out: BTreeMap<String, usize> = BTreeMap::new();
260    let mut file = String::new();
261    for line in patch.lines() {
262        if let Some(header) = line.strip_prefix("diff --git a/") {
263            file = header
264                .split_once(" b/")
265                .map_or(header, |(a, _)| a)
266                .to_owned();
267        } else if line.starts_with('-') && !line.starts_with("--- ") {
268            *out.entry(file.clone()).or_default() += 1;
269        }
270    }
271    out
272}
273
274/// Lowercase hex SHA-256 of `bytes`: the content hash of a judge input or a
275/// task list, as the format adapters' write guards compare it.
276#[must_use]
277pub fn sha256_hex(bytes: &[u8]) -> String {
278    Sha256::digest(bytes)
279        .iter()
280        .map(|b| format!("{b:02x}"))
281        .collect()
282}
283
284#[cfg(test)]
285mod tests {
286    use super::*;
287
288    fn change(path: &str, kind: ChangeKind) -> Change {
289        Change {
290            path: path.to_owned(),
291            kind,
292        }
293    }
294
295    #[test]
296    fn flags_tampering_and_passes_clean_diffs() {
297        let frozen: BTreeSet<String> = ["tests/api.rs".to_owned()].into();
298        let patch = "diff --git a/src/lib.rs b/src/lib.rs\n--- a/src/lib.rs\n+++ b/src/lib.rs\n@@ -1 +1 @@\n-    #[test]\n+    #[ignore]\n+    #[test]\n";
299        let f = audit(
300            &[
301                change("tests/api.rs", ChangeKind::Deleted),
302                change("speccoder.toml", ChangeKind::Modified),
303            ],
304            patch,
305            &frozen,
306            false,
307            &BTreeSet::new(),
308        );
309        assert_eq!(
310            f,
311            [
312                "frozen test deleted: tests/api.rs",
313                "verification input changed: speccoder.toml",
314                "skip marker added in src/lib.rs",
315            ]
316        );
317
318        let removed = "diff --git a/src/lib.rs b/src/lib.rs\n--- a/src/lib.rs\n+++ b/src/lib.rs\n@@ -5,4 +4,0 @@\n-    #[test]\n-    fn a() {}\n";
319        assert_eq!(
320            audit(&[], removed, &BTreeSet::new(), false, &BTreeSet::new()),
321            ["test removed in src/lib.rs (1 removed, 0 added)"]
322        );
323
324        let clean = "diff --git a/src/lib.rs b/src/lib.rs\n--- a/src/lib.rs\n+++ b/src/lib.rs\n@@ -1 +1,4 @@\n+pub fn add() {}\n+    #[test]\n+    fn adds() {}\n";
325        assert!(
326            audit(
327                &[
328                    change("src/lib.rs", ChangeKind::Modified),
329                    change("tests/new.rs", ChangeKind::Added)
330                ],
331                clean,
332                &frozen,
333                false,
334                &BTreeSet::new()
335            )
336            .is_empty()
337        );
338    }
339
340    #[test]
341    fn additions_to_a_frozen_test_pass_only_in_additions_mode() {
342        let frozen: BTreeSet<String> = ["tests/api.rs".to_owned()].into();
343        let added = "diff --git a/tests/api.rs b/tests/api.rs\n--- a/tests/api.rs\n+++ b/tests/api.rs\n@@ -1,2 +1,4 @@\n #[test]\n fn a() {}\n+#[test]\n+fn b() {}\n";
344        let changed = "diff --git a/tests/api.rs b/tests/api.rs\n--- a/tests/api.rs\n+++ b/tests/api.rs\n@@ -1,2 +1,2 @@\n #[test]\n-fn a() { assert!(x()) }\n+fn a() {}\n";
345        let modified = [change("tests/api.rs", ChangeKind::Modified)];
346        assert!(audit(&modified, added, &frozen, true, &BTreeSet::new()).is_empty());
347        assert_eq!(
348            audit(&modified, added, &frozen, false, &BTreeSet::new()),
349            ["frozen test modified: tests/api.rs"]
350        );
351        assert_eq!(
352            audit(&modified, changed, &frozen, true, &BTreeSet::new()),
353            ["frozen test modified: tests/api.rs"]
354        );
355
356        assert!(only_additions("a\nb\n", "a\nnew\nb\nmore\n"));
357        assert!(!only_additions("a\nb\n", "a\nB\n"));
358        assert!(!only_additions("a\nb\n", "b\na\n"));
359    }
360
361    #[test]
362    fn a_snapshot_sees_ignored_judge_inputs_and_reports_drift() {
363        use crate::tools::policy::Policy;
364        let dir = tempfile::tempdir().unwrap();
365        let root = dir.path();
366        std::fs::create_dir_all(root.join(".claude")).unwrap();
367        std::fs::create_dir_all(root.join("target/debug")).unwrap();
368        std::fs::write(root.join(".claude/settings.json"), "{}").unwrap();
369        std::fs::write(root.join("AGENTS.md"), "a").unwrap();
370        std::fs::write(root.join("speccoder.toml"), "x").unwrap();
371        std::fs::write(root.join("src.rs"), "free").unwrap();
372        std::fs::write(root.join("target/debug/speccoder.toml"), "skipped").unwrap();
373        let policy = Policy::new(&[], &[]);
374        let before = JudgeSnapshot::take(root, &policy);
375        assert_eq!(
376            before.0.len(),
377            3,
378            "{:?}",
379            before.0.keys().collect::<Vec<_>>()
380        );
381
382        std::fs::write(root.join(".claude/settings.local.json"), "{}").unwrap();
383        std::fs::write(root.join("AGENTS.md"), "b").unwrap();
384        std::fs::remove_file(root.join("speccoder.toml")).unwrap();
385        std::fs::write(root.join("src.rs"), "changed").unwrap();
386        let after = JudgeSnapshot::take(root, &policy);
387        let drift = before.drift(&after, &policy, &BTreeSet::new());
388        let seen: Vec<(&str, &str, &str)> = drift
389            .iter()
390            .map(|d| (d.path.as_str(), d.class, d.change))
391            .collect();
392        assert_eq!(
393            seen,
394            [
395                (".claude/settings.local.json", "judge", "added"),
396                ("AGENTS.md", "context", "modified"),
397                ("speccoder.toml", "judge", "deleted"),
398            ]
399        );
400        // A person's grant for the context file exempts it.
401        let approved: BTreeSet<String> = ["AGENTS.md".to_owned()].into();
402        assert_eq!(before.drift(&after, &policy, &approved).len(), 2);
403        // Restored content is no drift.
404        std::fs::write(root.join("AGENTS.md"), "a").unwrap();
405        std::fs::write(root.join("speccoder.toml"), "x").unwrap();
406        std::fs::remove_file(root.join(".claude/settings.local.json")).unwrap();
407        assert!(
408            before
409                .drift(
410                    &JudgeSnapshot::take(root, &policy),
411                    &policy,
412                    &BTreeSet::new()
413                )
414                .is_empty()
415        );
416    }
417}