Skip to main content

boundgraph_check/
scope.rs

1//! Did the patch touch anything it had no business touching?
2//!
3//! The sandbox answers "was the agent *able* to write that file". This answers
4//! a different and more useful question: "was that file in scope for this
5//! task". An agent asked to fix a parser, holding `fs:write:/src/**`, that also
6//! edits `/src/auth.rs` has done nothing the sandbox forbids and something
7//! nobody asked for.
8//!
9//! # It reads the raw patch, never the condensate payload
10//!
11//! A [`Condensate`] is *lossy on purpose* — the diff slicer (US-0704) drops
12//! whole hunks to fit a token cap, and a file whose every hunk was dropped
13//! keeps only a summary line. Scope-checking that would under-report the change
14//! set, and under-reporting is the one error this checker must not make: it
15//! would produce a `Pass` that says "nothing out of scope" about a patch it
16//! only partly saw.
17//!
18//! So this fetches [`Condensate::raw`] from the content store and parses the
19//! complete original. That is exactly what `raw` exists for, and it is the
20//! criterion "the check runs on the actual diff, not on the model's description
21//! of it" made structural rather than aspirational.
22//!
23//! # The parser is strict, unlike the slicer's
24//!
25//! `DiffSlicer::parse` is deliberately tolerant: a patch with one header line
26//! it does not recognise is still mostly a patch, and refusing to summarise a
27//! build's diff over it would be worse than ignoring the line. The trade goes
28//! the other way here. A line this parser does not understand might be the one
29//! introducing the file that should not have been touched, so an unrecognised
30//! construct is [`Verdict::Inconclusive`](boundgraph_core::Verdict::Inconclusive) and
31//! nothing commits.
32//!
33//! Hunks are consumed by the line counts in their own `@@` headers rather than
34//! by scanning for the next header, so patch *content* that looks like a header
35//! cannot end a hunk early and hide the files that follow.
36//!
37//! # What counts as touching a file
38//!
39//! Content edits, additions, deletions, both halves of a rename, and **mode
40//! changes**. The last is easy to leave out and worth having: `chmod +x` on a
41//! script in a deployment directory changes what runs, without changing a byte
42//! of content.
43//!
44//! # Which grant it checks against
45//!
46//! The one the step was *granted*, handed to the checker when it is built —
47//! never [`WorkOrder::requests`], which is what the model asked for. A checker
48//! that scoped a patch against the proposer's own request would let the
49//! proposer define the scope it is being held to, which is not a check.
50
51use async_trait::async_trait;
52use boundgraph_core::{CheckerId, Condensate, Digest, GrantAtom, GrantSet, WorkOrder};
53use boundgraph_grant::{Tri, set_leq};
54use boundgraph_slice::Store;
55
56use crate::checker::{Checker, Judgement};
57
58/// How many offending paths are named before the rest are counted.
59pub const MAX_NAMED: usize = 20;
60
61/// What happened to a file.
62#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
63pub enum ChangeKind {
64    /// The file is new.
65    Added,
66    /// The file is gone.
67    Deleted,
68    /// The file changed in place.
69    Modified,
70    /// The file moved, possibly with changes.
71    Renamed,
72}
73
74impl ChangeKind {
75    /// How it reads in a diagnostic.
76    pub fn describe(self) -> &'static str {
77        match self {
78            Self::Added => "added",
79            Self::Deleted => "deleted",
80            Self::Modified => "modified",
81            Self::Renamed => "renamed",
82        }
83    }
84
85    /// The `fs` action that most specifically authorises this change.
86    ///
87    /// Checked *in addition to* `fs:write`, never instead of it — see
88    /// [`in_scope`].
89    fn action(self) -> &'static str {
90        match self {
91            Self::Added => "create",
92            Self::Deleted => "delete",
93            Self::Modified | Self::Renamed => "write",
94        }
95    }
96}
97
98/// One file a patch touched.
99#[derive(Debug, Clone, PartialEq, Eq)]
100pub struct Change {
101    /// The path after the change. For a deletion, the path that is gone.
102    pub path: String,
103    /// The path before it, on a rename.
104    pub from: Option<String>,
105    /// What happened.
106    pub kind: ChangeKind,
107    /// The file mode before, where the patch stated one.
108    pub old_mode: Option<String>,
109    /// The mode after.
110    pub new_mode: Option<String>,
111    /// Whether the change was binary, and so shown only as a summary.
112    pub binary: bool,
113}
114
115impl Change {
116    /// Every path this change touches.
117    ///
118    /// Two for a rename: both the source and the destination have to be in
119    /// scope, because a rename is a deletion and a creation wearing one header.
120    pub fn paths(&self) -> Vec<&str> {
121        match &self.from {
122            Some(from) if from != &self.path => vec![from.as_str(), self.path.as_str()],
123            _ => vec![self.path.as_str()],
124        }
125    }
126
127    /// Whether the file's mode or permissions changed.
128    pub fn mode_changed(&self) -> bool {
129        match (&self.old_mode, &self.new_mode) {
130            (Some(old), Some(new)) => old != new,
131            _ => false,
132        }
133    }
134
135    /// A one-line description, as it appears in a verdict.
136    pub fn describe(&self) -> String {
137        let mode = match (&self.old_mode, &self.new_mode) {
138            (Some(old), Some(new)) if old != new => format!(", mode {old} -> {new}"),
139            _ => String::new(),
140        };
141        match &self.from {
142            Some(from) if from != &self.path => {
143                format!("{from} -> {} ({}{mode})", self.path, self.kind.describe())
144            }
145            _ => format!("{} ({}{mode})", self.path, self.kind.describe()),
146        }
147    }
148}
149
150/// The patch could not be understood, so no scope claim can be made about it.
151#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
152#[non_exhaustive]
153pub enum PatchError {
154    /// A line this parser does not recognise.
155    ///
156    /// A refusal, not something to skip. That line might be the one introducing
157    /// the file that should not have been touched.
158    #[error("line {line_number}: unrecognised in a patch: {line:?}")]
159    Unrecognised {
160        /// Which line, counting from one.
161        line_number: usize,
162        /// The line itself.
163        line: String,
164    },
165
166    /// A hunk header that does not state usable line counts.
167    #[error("line {line_number}: malformed hunk header: {header:?}")]
168    MalformedHunk {
169        /// Which line, counting from one.
170        line_number: usize,
171        /// The header.
172        header: String,
173    },
174
175    /// The patch ends part way through a hunk.
176    #[error("the patch ends inside a hunk that began at line {line_number}")]
177    Truncated {
178        /// Where the unfinished hunk started.
179        line_number: usize,
180    },
181
182    /// A header whose paths cannot be read unambiguously.
183    #[error("line {line_number}: cannot tell which paths this names: {line:?}")]
184    Ambiguous {
185        /// Which line, counting from one.
186        line_number: usize,
187        /// The line itself.
188        line: String,
189    },
190}
191
192/// File-header lines that carry no path and change no scope.
193const IGNORED_HEADERS: &[&str] = &[
194    "index ",
195    "similarity index ",
196    "dissimilarity index ",
197    "old file mode ",
198    "GIT binary patch",
199    "Binary files ",
200    // "\ No newline at end of file" belongs to the hunk line before it and
201    // counts against neither side, so a hunk whose counts are already satisfied
202    // leaves it sitting at file level.
203    "\\ ",
204];
205
206/// Parse a unified diff into the set of files it touches.
207///
208/// Strict: anything it does not recognise is an error rather than a skipped
209/// line. See the module documentation for why the trade goes the opposite way
210/// from the diff slicer's.
211///
212/// # Errors
213///
214/// [`PatchError`] if the patch contains a construct this parser cannot account
215/// for, a malformed hunk header, or an unfinished hunk.
216pub fn parse_patch(text: &str) -> Result<Vec<Change>, PatchError> {
217    let lines: Vec<&str> = text.lines().collect();
218    let mut changes: Vec<Change> = Vec::new();
219    // Kept alongside `changes` rather than inside `Change`: the line a file
220    // header opened on is needed to report an unreadable one, and whether a
221    // `---` has been seen is needed to tell one file from the next in a patch
222    // with no `diff --git` headers. Neither belongs in the public type.
223    let mut starts: Vec<usize> = Vec::new();
224    let mut seen_minus: Vec<bool> = Vec::new();
225    let mut index = 0usize;
226
227    while index < lines.len() {
228        let line = lines[index];
229        let number = index + 1;
230
231        // A `diff --git` header opens a file. The paths on it are advisory:
232        // `---`/`+++` and the rename lines below are authoritative, because
233        // this line cannot be split unambiguously when a name contains a space.
234        if let Some(rest) = line.strip_prefix("diff --git ") {
235            changes.push(Change {
236                path: advisory_path(rest).unwrap_or_default(),
237                from: None,
238                kind: ChangeKind::Modified,
239                old_mode: None,
240                new_mode: None,
241                binary: false,
242            });
243            starts.push(number);
244            seen_minus.push(false);
245            index += 1;
246            continue;
247        }
248
249        // A bare unified diff, with no `diff --git` preamble. `diff -ru` emits
250        // several files this way, one after another, so a new `---` when the
251        // current file already had one opens the next file rather than
252        // overwriting the previous — which would drop everything before it.
253        if line.starts_with("--- ") && seen_minus.last().is_none_or(|seen| *seen) {
254            changes.push(Change {
255                path: String::new(),
256                from: None,
257                kind: ChangeKind::Modified,
258                old_mode: None,
259                new_mode: None,
260                binary: false,
261            });
262            starts.push(number);
263            seen_minus.push(false);
264        }
265
266        let Some(current) = changes.last_mut() else {
267            // Anything before the first file header. A leading commit message
268            // is normal in `git format-patch` output, but this parser is not in
269            // the business of guessing where prose ends, so it refuses.
270            return Err(PatchError::Unrecognised {
271                line_number: number,
272                line: line.to_string(),
273            });
274        };
275
276        if let Some(rest) = line.strip_prefix("@@") {
277            let (old, new) = hunk_counts(rest).ok_or_else(|| PatchError::MalformedHunk {
278                line_number: number,
279                header: line.to_string(),
280            })?;
281            index = consume_hunk(&lines, index + 1, old, new, number)?;
282            continue;
283        }
284
285        if let Some(rest) = line.strip_prefix("--- ") {
286            if rest == "/dev/null" {
287                current.kind = ChangeKind::Added;
288            } else {
289                let path = strip_prefix_marker(rest, 'a').ok_or_else(|| PatchError::Ambiguous {
290                    line_number: number,
291                    line: line.to_string(),
292                })?;
293                if current.path.is_empty() {
294                    current.path = path.clone();
295                }
296                current.from.get_or_insert(path);
297            }
298            if let Some(seen) = seen_minus.last_mut() {
299                *seen = true;
300            }
301            index += 1;
302            continue;
303        }
304
305        if let Some(rest) = line.strip_prefix("+++ ") {
306            if rest == "/dev/null" {
307                current.kind = ChangeKind::Deleted;
308            } else {
309                let path = strip_prefix_marker(rest, 'b').ok_or_else(|| PatchError::Ambiguous {
310                    line_number: number,
311                    line: line.to_string(),
312                })?;
313                current.path = path;
314            }
315            index += 1;
316            continue;
317        }
318
319        if let Some(mode) = line.strip_prefix("new file mode ") {
320            current.kind = ChangeKind::Added;
321            current.new_mode = Some(mode.trim().to_string());
322            index += 1;
323            continue;
324        }
325        if let Some(mode) = line.strip_prefix("deleted file mode ") {
326            current.kind = ChangeKind::Deleted;
327            current.old_mode = Some(mode.trim().to_string());
328            index += 1;
329            continue;
330        }
331        if let Some(mode) = line.strip_prefix("old mode ") {
332            current.old_mode = Some(mode.trim().to_string());
333            index += 1;
334            continue;
335        }
336        if let Some(mode) = line.strip_prefix("new mode ") {
337            current.new_mode = Some(mode.trim().to_string());
338            index += 1;
339            continue;
340        }
341        if let Some(from) = line.strip_prefix("rename from ") {
342            current.kind = ChangeKind::Renamed;
343            current.from = Some(from.trim().to_string());
344            index += 1;
345            continue;
346        }
347        if let Some(to) = line.strip_prefix("rename to ") {
348            current.kind = ChangeKind::Renamed;
349            current.path = to.trim().to_string();
350            index += 1;
351            continue;
352        }
353        if let Some(from) = line.strip_prefix("copy from ") {
354            // A copy creates the destination and leaves the source alone, but
355            // both are named so both are checked. Being generous with what is
356            // in the change set is the safe direction here.
357            current.from = Some(from.trim().to_string());
358            index += 1;
359            continue;
360        }
361        if let Some(to) = line.strip_prefix("copy to ") {
362            current.kind = ChangeKind::Added;
363            current.path = to.trim().to_string();
364            index += 1;
365            continue;
366        }
367
368        if line.starts_with("Binary files ") || line.starts_with("GIT binary patch") {
369            current.binary = true;
370        }
371
372        if IGNORED_HEADERS.iter().any(|h| line.starts_with(h)) || line.is_empty() {
373            index += 1;
374            continue;
375        }
376
377        // A binary patch body is base85 with a length byte in front. It is not
378        // parsed, and it is not skipped by guesswork either: once `binary` is
379        // set the body runs to the next file header or the end.
380        if current.binary {
381            index += 1;
382            continue;
383        }
384
385        return Err(PatchError::Unrecognised {
386            line_number: number,
387            line: line.to_string(),
388        });
389    }
390
391    for (position, change) in changes.iter_mut().enumerate() {
392        if change.path.is_empty()
393            && let Some(from) = &change.from
394        {
395            change.path = from.clone();
396        }
397
398        // A file header whose path could never be read. Refused rather than
399        // dropped: dropping it would leave the change set short by exactly the
400        // file nobody could name, and a short change set is a `Pass` about a
401        // patch this checker did not fully see. It happens for a mode-only
402        // change to a path containing a space, where the `diff --git` line
403        // cannot be split and there are no `---`/`+++` lines to settle it.
404        if change.path.is_empty() {
405            let line_number = starts.get(position).copied().unwrap_or(1);
406            return Err(PatchError::Ambiguous {
407                line_number,
408                line: lines
409                    .get(line_number.saturating_sub(1))
410                    .unwrap_or(&"")
411                    .to_string(),
412            });
413        }
414    }
415    Ok(changes)
416}
417
418/// The `b/` path from a `diff --git` line, when it can be read unambiguously.
419fn advisory_path(rest: &str) -> Option<String> {
420    let parts: Vec<&str> = rest.split_whitespace().collect();
421    if parts.len() != 2 {
422        // A name with a space in it. `---`/`+++` will settle it.
423        return None;
424    }
425    strip_prefix_marker(parts[1], 'b')
426}
427
428/// Strip git's `a/` or `b/` prefix, and its quoting.
429fn strip_prefix_marker(text: &str, marker: char) -> Option<String> {
430    // `--- a/src/main.rs\t2026-01-01` — some tools append a timestamp.
431    let text = text.split('\t').next().unwrap_or(text).trim_end();
432    let text = text.trim_matches('"');
433
434    let prefix = format!("{marker}/");
435    match text.strip_prefix(&prefix) {
436        Some(path) => Some(path.to_string()),
437        // A plain unified diff has no `a/`, `b/` prefixes at all.
438        None if !text.is_empty() && text != "/dev/null" => Some(text.to_string()),
439        None => None,
440    }
441}
442
443/// The old and new line counts from a `@@ -l,s +l,s @@` header.
444fn hunk_counts(rest: &str) -> Option<(usize, usize)> {
445    let inner = rest.split("@@").next()?.trim();
446    let mut ranges = inner.split_whitespace();
447
448    let old = ranges.next()?.strip_prefix('-')?;
449    let new = ranges.next()?.strip_prefix('+')?;
450
451    Some((span(old)?, span(new)?))
452}
453
454/// The length half of a `start,length` range. An absent length means one.
455fn span(range: &str) -> Option<usize> {
456    match range.split_once(',') {
457        Some((start, length)) => {
458            start.parse::<usize>().ok()?;
459            length.parse().ok()
460        }
461        None => {
462            range.parse::<usize>().ok()?;
463            Some(1)
464        }
465    }
466}
467
468/// Consume exactly the lines a hunk header promised, returning the index after.
469///
470/// Counting rather than scanning for the next header is what stops patch
471/// *content* that happens to look like a header from ending a hunk early and
472/// hiding the files that follow it.
473fn consume_hunk(
474    lines: &[&str],
475    mut index: usize,
476    mut old: usize,
477    mut new: usize,
478    started_at: usize,
479) -> Result<usize, PatchError> {
480    while old > 0 || new > 0 {
481        let Some(line) = lines.get(index) else {
482            return Err(PatchError::Truncated {
483                line_number: started_at,
484            });
485        };
486        index += 1;
487
488        match line.chars().next() {
489            Some('+') => new = new.saturating_sub(1),
490            Some('-') => old = old.saturating_sub(1),
491            // "\ No newline at end of file" belongs to the line before it and
492            // counts against neither side.
493            Some('\\') => {}
494            // A context line. Some tools strip the trailing space from an
495            // otherwise empty one, which is why an empty line counts as
496            // context rather than being an error.
497            Some(' ') | None => {
498                old = old.saturating_sub(1);
499                new = new.saturating_sub(1);
500            }
501            Some(_) => {
502                return Err(PatchError::Unrecognised {
503                    line_number: index,
504                    line: (*line).to_string(),
505                });
506            }
507        }
508    }
509    Ok(index)
510}
511
512/// Whether `path` may be changed under `scope`.
513///
514/// Asks for `fs:write` first, and then for the action most specific to the kind
515/// of change. Both, rather than only the specific one: a flow that grants
516/// `fs:write:/src/**` plainly means to permit deleting a file under `/src`, and
517/// a check that refused it because the atom said `write` and the change said
518/// `delete` would be pedantry rather than security. And both rather than only
519/// `fs:write`, so a narrow `fs:delete:/tmp/**` still authorises what it names.
520fn in_scope(scope: &GrantSet, path: &str, kind: ChangeKind) -> Tri {
521    let ask = |action: &str| set_leq(&GrantSet::new([GrantAtom::new("fs", action, path)]), scope);
522    ask("write").or(ask(kind.action()))
523}
524
525/// Confirms that a patch only touched files the step was granted.
526///
527/// # Which grant
528///
529/// The one the step was **granted**, passed in here. Never the work order's
530/// `requests`: that is what the model asked for, and a checker that scoped a
531/// patch against the proposer's own request would be asking the proposer to
532/// mark its own homework.
533#[derive(Debug)]
534pub struct DiffScope<S: Store> {
535    store: S,
536    scope: GrantSet,
537    root: Option<String>,
538    id: CheckerId,
539}
540
541impl<S: Store> DiffScope<S> {
542    /// A scope checker over `scope`, reading raw patches from `store`.
543    pub fn new(store: S, scope: GrantSet) -> Self {
544        Self {
545            store,
546            scope,
547            root: None,
548            id: CheckerId::new("diff-scope", 1),
549        }
550    }
551
552    /// Treat patch paths as relative to `root` before comparing them.
553    ///
554    /// A patch says `src/auth.rs`; a grant says `fs:write:/work/repo/src/**`.
555    /// The two only meet if the checker knows which directory the patch is
556    /// relative to, so set this to the workspace the grant is written against.
557    ///
558    /// With no root the patch path is compared as an absolute path from the
559    /// repository root — `src/auth.rs` becomes `/src/auth.rs` — which is how
560    /// every grant in this codebase is spelled.
561    #[must_use]
562    pub fn rooted_at(mut self, root: impl Into<String>) -> Self {
563        let root = root.into().replace('\\', "/");
564        self.root = Some(root.trim_end_matches('/').to_string());
565        self
566    }
567
568    /// Name it something more specific, so a ledger says which scope this was.
569    #[must_use]
570    pub fn named(mut self, name: impl Into<String>, version: u32) -> Self {
571        self.id = CheckerId::new(name, version);
572        self
573    }
574
575    /// The path as the grant would spell it.
576    ///
577    /// Always absolute. A grant resource is matched segment by segment, so
578    /// `src/auth.rs` and `/src/**` would never meet — and the mismatch would
579    /// look like an overreach report rather than a configuration mistake, which
580    /// is the worst way for this checker to be wrong.
581    fn resolve(&self, path: &str) -> String {
582        let path = path.replace('\\', "/");
583        let path = path.trim_start_matches('/');
584        match &self.root {
585            Some(root) => format!("{root}/{path}"),
586            None => format!("/{path}"),
587        }
588    }
589}
590
591/// Name at most [`MAX_NAMED`] entries, counting the rest.
592fn listing(items: &[String]) -> String {
593    let shown: Vec<&str> = items.iter().take(MAX_NAMED).map(String::as_str).collect();
594    let rest = items.len().saturating_sub(shown.len());
595    if rest == 0 {
596        shown.join(", ")
597    } else {
598        format!("{}, and {rest} more", shown.join(", "))
599    }
600}
601
602#[async_trait]
603impl<S: Store> Checker for DiffScope<S> {
604    fn id(&self) -> CheckerId {
605        self.id.clone()
606    }
607
608    // No grants. The store is handed to this checker rather than reached for,
609    // so it reads no file and runs no process. Note that `scope` is not this
610    // checker's own authority — it is the authority being checked *against*.
611
612    async fn judge(&self, _order: &WorkOrder, condensate: &Condensate) -> Judgement {
613        // The raw patch, not the condensate payload. See the module docs: the
614        // payload is lossy by design and would under-report the change set.
615        let raw = match self.store.get(&condensate.raw).await {
616            Ok(bytes) => bytes,
617            Err(e) => {
618                return Judgement::inconclusive(format!(
619                    "the original patch could not be read from the content store: {e}"
620                ));
621            }
622        };
623
624        let text = match String::from_utf8(raw) {
625            Ok(text) => text,
626            Err(_) => {
627                return Judgement::inconclusive(
628                    "the original patch is not valid UTF-8, so its paths cannot be read"
629                        .to_string(),
630                );
631            }
632        };
633
634        let changes = match parse_patch(&text) {
635            Ok(changes) => changes,
636            Err(e) => {
637                return Judgement::inconclusive(format!(
638                    "the patch could not be parsed, so nothing can be claimed about its scope: {e}"
639                ));
640            }
641        };
642
643        let mut touched: Vec<String> = Vec::new();
644        let mut outside: Vec<String> = Vec::new();
645        let mut undecided: Vec<String> = Vec::new();
646
647        for change in &changes {
648            for path in change.paths() {
649                let resolved = self.resolve(path);
650                if !touched.contains(&resolved) {
651                    touched.push(resolved.clone());
652                }
653                match in_scope(&self.scope, &resolved, change.kind) {
654                    Tri::Yes => {}
655                    Tri::No => outside.push(format!("{resolved} ({})", change.describe())),
656                    Tri::Unknown => undecided.push(resolved),
657                }
658            }
659        }
660
661        touched.sort();
662
663        // The exact set, recorded so an auditor can ask "did this patch touch
664        // `src/auth.rs`" and get an answer bound by the signature rather than a
665        // sentence someone wrote. One digest per path, plus one for the set.
666        let mut judgement = Judgement::pass();
667        for path in &touched {
668            judgement = judgement.covering(Digest::of(path.as_bytes()));
669        }
670        judgement = judgement.covering(Digest::of(touched.join("\n").as_bytes()));
671
672        if !outside.is_empty() {
673            return Judgement {
674                verdict: boundgraph_core::Verdict::Fail(format!(
675                    "{} path{} outside the step's grant: {}",
676                    outside.len(),
677                    if outside.len() == 1 { "" } else { "s" },
678                    listing(&outside)
679                )),
680                also: judgement.also,
681            };
682        }
683
684        if !undecided.is_empty() {
685            return Judgement {
686                verdict: boundgraph_core::Verdict::Inconclusive(format!(
687                    "cannot prove {} path{} within the step's grant: {}",
688                    undecided.len(),
689                    if undecided.len() == 1 { "" } else { "s" },
690                    listing(&undecided)
691                )),
692                also: judgement.also,
693            };
694        }
695
696        judgement
697    }
698}
699
700#[cfg(test)]
701mod tests {
702    use super::*;
703    use crate::sign::LocalKey;
704    use boundgraph_core::{CondensatePayload, Intent, ProposalOrigin, SchemaId, SlicerId, Verdict};
705    use boundgraph_slice::MemoryStore;
706
707    fn order() -> WorkOrder {
708        WorkOrder::new(
709            Intent::new("patch.apply", &"fix the header parser").unwrap(),
710            vec![],
711            // Deliberately wide: the checker must ignore it and use the granted
712            // scope instead.
713            GrantSet::parse(["fs:write:/**"]).unwrap(),
714            ProposalOrigin::new("claude-opus-5", [], Digest::of(b"prompt")),
715        )
716    }
717
718    fn condensate_for(raw: &[u8]) -> Condensate {
719        Condensate::new(
720            SchemaId::new("boundgraph.diff.v1", 1),
721            // Deliberately a summary that says something different from the
722            // patch: the checker must not read it.
723            CondensatePayload::inline(&"1 file changed in src/").unwrap(),
724            Digest::of(raw),
725            400,
726            true,
727            SlicerId::new("diff", 1),
728        )
729    }
730
731    fn key() -> LocalKey {
732        LocalKey::from_seed("host-key-1", [7u8; 32])
733    }
734
735    fn scope(atoms: &[&str]) -> GrantSet {
736        GrantSet::parse(atoms.iter().copied()).expect("test scope should parse")
737    }
738
739    async fn verdict_for(patch: &str, granted: &[&str]) -> Verdict {
740        let store = MemoryStore::new();
741        store.put(patch.as_bytes()).await.unwrap();
742
743        DiffScope::new(store, scope(granted))
744            .check(&order(), &condensate_for(patch.as_bytes()), &key())
745            .await
746            .unwrap()
747            .verdict
748    }
749
750    const IN_SCOPE: &str = "\
751diff --git a/src/parse.rs b/src/parse.rs
752--- a/src/parse.rs
753+++ b/src/parse.rs
754@@ -1,3 +1,3 @@
755 fn parse() {
756-    old();
757+    new();
758 }
759";
760
761    const REACHES_OUT: &str = "\
762diff --git a/src/parse.rs b/src/parse.rs
763--- a/src/parse.rs
764+++ b/src/parse.rs
765@@ -1,1 +1,1 @@
766-old();
767+new();
768diff --git a/deploy/production.yaml b/deploy/production.yaml
769--- a/deploy/production.yaml
770+++ b/deploy/production.yaml
771@@ -1,1 +1,1 @@
772-replicas: 1
773+replicas: 99
774";
775
776    // ── the check ───────────────────────────────────────────────────────────
777
778    #[tokio::test]
779    async fn a_patch_inside_the_grant_passes() {
780        assert_eq!(
781            verdict_for(IN_SCOPE, &["fs:write:/src/**"]).await,
782            Verdict::Pass
783        );
784    }
785
786    /// The criterion: a path outside the grant is a `Fail` naming the file.
787    #[tokio::test]
788    async fn a_path_outside_the_grant_fails_and_names_it() {
789        let v = verdict_for(REACHES_OUT, &["fs:write:/src/**"]).await;
790
791        let Verdict::Fail(reason) = v else {
792            panic!("expected Fail, got {v:?}");
793        };
794        assert!(reason.contains("deploy/production.yaml"), "{reason}");
795        assert!(
796            !reason.contains("src/parse.rs"),
797            "only the offender: {reason}"
798        );
799        assert!(reason.contains("1 path outside"), "{reason}");
800    }
801
802    /// The criterion: deletions are checked too.
803    #[tokio::test]
804    async fn a_deletion_outside_the_grant_is_caught() {
805        let patch = "\
806diff --git a/deploy/secrets.env b/deploy/secrets.env
807deleted file mode 100644
808--- a/deploy/secrets.env
809+++ /dev/null
810@@ -1,1 +0,0 @@
811-TOKEN=abc
812";
813        let v = verdict_for(patch, &["fs:write:/src/**"]).await;
814
815        let Verdict::Fail(reason) = v else {
816            panic!("expected Fail, got {v:?}");
817        };
818        assert!(reason.contains("deploy/secrets.env"), "{reason}");
819        assert!(reason.contains("deleted"), "{reason}");
820    }
821
822    /// The criterion: renames are checked too — and both halves of one, because
823    /// a rename is a deletion and a creation wearing a single header.
824    #[tokio::test]
825    async fn both_halves_of_a_rename_must_be_in_scope() {
826        let patch = "\
827diff --git a/src/parse.rs b/deploy/parse.rs
828similarity index 100%
829rename from src/parse.rs
830rename to deploy/parse.rs
831";
832        let v = verdict_for(patch, &["fs:write:/src/**"]).await;
833
834        let Verdict::Fail(reason) = v else {
835            panic!("moving a file out of scope must fail, got {v:?}");
836        };
837        assert!(reason.contains("deploy/parse.rs"), "{reason}");
838
839        // And the other direction: moving something in from outside.
840        let inbound = "\
841diff --git a/deploy/parse.rs b/src/parse.rs
842similarity index 100%
843rename from deploy/parse.rs
844rename to src/parse.rs
845";
846        let v = verdict_for(inbound, &["fs:write:/src/**"]).await;
847        assert!(matches!(v, Verdict::Fail(_)), "{v:?}");
848    }
849
850    /// The criterion: mode and permission changes count, not just content.
851    #[tokio::test]
852    async fn a_mode_change_alone_is_a_change() {
853        let patch = "\
854diff --git a/deploy/run.sh b/deploy/run.sh
855old mode 100644
856new mode 100755
857";
858        let v = verdict_for(patch, &["fs:write:/src/**"]).await;
859
860        let Verdict::Fail(reason) = v else {
861            panic!("chmod +x on an out-of-scope file must fail, got {v:?}");
862        };
863        assert!(reason.contains("deploy/run.sh"), "{reason}");
864        assert!(reason.contains("100644 -> 100755"), "{reason}");
865    }
866
867    #[tokio::test]
868    async fn a_mode_change_inside_the_grant_still_passes() {
869        let patch = "\
870diff --git a/src/run.sh b/src/run.sh
871old mode 100644
872new mode 100755
873";
874        assert_eq!(
875            verdict_for(patch, &["fs:write:/src/**"]).await,
876            Verdict::Pass
877        );
878    }
879
880    #[tokio::test]
881    async fn an_addition_is_authorised_by_write_or_by_create() {
882        let patch = "\
883diff --git a/src/new.rs b/src/new.rs
884new file mode 100644
885--- /dev/null
886+++ b/src/new.rs
887@@ -0,0 +1,1 @@
888+fn main() {}
889";
890        assert_eq!(
891            verdict_for(patch, &["fs:write:/src/**"]).await,
892            Verdict::Pass
893        );
894        assert_eq!(
895            verdict_for(patch, &["fs:create:/src/**"]).await,
896            Verdict::Pass
897        );
898    }
899
900    #[tokio::test]
901    async fn a_narrow_delete_grant_authorises_a_deletion() {
902        let patch = "\
903diff --git a/tmp/scratch b/tmp/scratch
904deleted file mode 100644
905--- a/tmp/scratch
906+++ /dev/null
907@@ -1,1 +0,0 @@
908-junk
909";
910        assert_eq!(
911            verdict_for(patch, &["fs:delete:/tmp/**"]).await,
912            Verdict::Pass
913        );
914    }
915
916    #[tokio::test]
917    async fn an_empty_patch_touches_nothing_and_passes() {
918        assert_eq!(verdict_for("", &["fs:write:/src/**"]).await, Verdict::Pass);
919    }
920
921    /// Goal G5: what cannot be decided is not assumed.
922    #[tokio::test]
923    async fn an_undecidable_comparison_is_inconclusive_rather_than_a_pass() {
924        // A partial-segment glob is outside the decidable fragment.
925        let v = verdict_for(IN_SCOPE, &["fs:write:/src/*.rs"]).await;
926
927        assert!(matches!(v, Verdict::Inconclusive(_)), "{v:?}");
928        assert!(!v.is_pass());
929        assert!(v.reason().unwrap().contains("src/parse.rs"));
930    }
931
932    /// The criterion: the passing verdict records the exact set of paths.
933    #[tokio::test]
934    async fn a_pass_records_exactly_which_paths_changed() {
935        let patch = "\
936diff --git a/src/a.rs b/src/a.rs
937--- a/src/a.rs
938+++ b/src/a.rs
939@@ -1,1 +1,1 @@
940-a
941+A
942diff --git a/src/b.rs b/src/b.rs
943--- a/src/b.rs
944+++ b/src/b.rs
945@@ -1,1 +1,1 @@
946-b
947+B
948";
949        let store = MemoryStore::new();
950        store.put(patch.as_bytes()).await.unwrap();
951
952        let evidence = DiffScope::new(store, scope(&["fs:write:/src/**"]))
953            .check(&order(), &condensate_for(patch.as_bytes()), &key())
954            .await
955            .unwrap();
956
957        assert!(evidence.permits_commit());
958        assert!(evidence.covers(&Digest::of(b"/src/a.rs")));
959        assert!(evidence.covers(&Digest::of(b"/src/b.rs")));
960        assert!(
961            !evidence.covers(&Digest::of(b"/src/c.rs")),
962            "a file it never touched must not be vouched for"
963        );
964        assert!(
965            evidence.covers(&Digest::of(b"/src/a.rs\n/src/b.rs")),
966            "and the set as a whole, so nothing can be added or removed"
967        );
968    }
969
970    // ── reading the real patch ──────────────────────────────────────────────
971
972    /// The criterion: the check runs on the actual diff. The condensate here
973    /// says one thing and the raw patch says another.
974    #[tokio::test]
975    async fn the_check_reads_the_raw_patch_and_not_the_condensate_payload() {
976        let store = MemoryStore::new();
977        store.put(REACHES_OUT.as_bytes()).await.unwrap();
978
979        // The payload claims a single in-scope file; the raw patch escapes.
980        let condensate = condensate_for(REACHES_OUT.as_bytes());
981        assert!(condensate.lossy, "the summary is the model's-eye view");
982
983        let evidence = DiffScope::new(store, scope(&["fs:write:/src/**"]))
984            .check(&order(), &condensate, &key())
985            .await
986            .unwrap();
987
988        assert!(
989            !evidence.permits_commit(),
990            "the summary said in scope; the patch was not"
991        );
992    }
993
994    #[tokio::test]
995    async fn a_raw_patch_that_is_not_in_the_store_is_inconclusive() {
996        let evidence = DiffScope::new(MemoryStore::new(), scope(&["fs:write:/src/**"]))
997            .check(&order(), &condensate_for(b"never stored"), &key())
998            .await
999            .unwrap();
1000
1001        assert!(matches!(evidence.verdict, Verdict::Inconclusive(_)));
1002    }
1003
1004    /// The proposer must not get to define the scope it is held to.
1005    #[tokio::test]
1006    async fn the_work_orders_own_request_is_ignored() {
1007        // `order()` requests `fs:write:/**`, which would permit everything.
1008        let v = verdict_for(REACHES_OUT, &["fs:write:/src/**"]).await;
1009        assert!(matches!(v, Verdict::Fail(_)), "{v:?}");
1010    }
1011
1012    #[tokio::test]
1013    async fn the_checker_needs_no_authority_of_its_own() {
1014        let checker = DiffScope::new(MemoryStore::new(), scope(&["fs:write:/src/**"]));
1015        assert_eq!(checker.grants(), GrantSet::EMPTY);
1016    }
1017
1018    #[tokio::test]
1019    async fn paths_can_be_resolved_against_a_workspace_root() {
1020        let store = MemoryStore::new();
1021        store.put(IN_SCOPE.as_bytes()).await.unwrap();
1022
1023        let evidence = DiffScope::new(store, scope(&["fs:write:/work/repo/src/**"]))
1024            .rooted_at("/work/repo/")
1025            .check(&order(), &condensate_for(IN_SCOPE.as_bytes()), &key())
1026            .await
1027            .unwrap();
1028
1029        assert!(evidence.permits_commit());
1030        assert!(evidence.covers(&Digest::of(b"/work/repo/src/parse.rs")));
1031    }
1032
1033    #[tokio::test]
1034    async fn the_same_patch_always_gives_the_same_evidence() {
1035        let store = MemoryStore::new();
1036        store.put(IN_SCOPE.as_bytes()).await.unwrap();
1037        let checker = DiffScope::new(store, scope(&["fs:write:/src/**"]));
1038        let (order, k, key) = (order(), condensate_for(IN_SCOPE.as_bytes()), key());
1039
1040        let first = checker.check(&order, &k, &key).await.unwrap();
1041        for _ in 0..4 {
1042            assert_eq!(checker.check(&order, &k, &key).await.unwrap(), first);
1043        }
1044    }
1045
1046    // ── the strict parser ───────────────────────────────────────────────────
1047
1048    #[test]
1049    fn a_plain_unified_diff_with_no_git_preamble_parses() {
1050        let patch = "\
1051--- src/old.txt
1052+++ src/new.txt
1053@@ -1,1 +1,1 @@
1054-a
1055+b
1056";
1057        let changes = parse_patch(patch).unwrap();
1058        assert_eq!(changes.len(), 1);
1059        assert_eq!(changes[0].path, "src/new.txt");
1060    }
1061
1062    #[test]
1063    fn a_hunk_is_consumed_by_its_own_line_counts() {
1064        // The hunk body contains lines that look like file headers. Scanning
1065        // for the next header would end the hunk here and lose `evil.sh`.
1066        let patch = "\
1067diff --git a/src/a.rs b/src/a.rs
1068--- a/src/a.rs
1069+++ b/src/a.rs
1070@@ -1,3 +1,3 @@
1071 context
1072-diff --git a/x b/x
1073+diff --git a/x b/x
1074 @@ -9,9 +9,9 @@
1075diff --git a/evil.sh b/evil.sh
1076--- a/evil.sh
1077+++ b/evil.sh
1078@@ -1,1 +1,1 @@
1079-x
1080+y
1081";
1082        let changes = parse_patch(patch).unwrap();
1083        let paths: Vec<&str> = changes.iter().map(|c| c.path.as_str()).collect();
1084
1085        assert_eq!(
1086            paths,
1087            vec!["src/a.rs", "evil.sh"],
1088            "the second file must survive"
1089        );
1090    }
1091
1092    /// The trade that goes the opposite way from the diff slicer's: a line this
1093    /// parser does not understand might be the one hiding a file.
1094    #[test]
1095    fn an_unrecognised_line_is_refused_rather_than_skipped() {
1096        let patch = "\
1097diff --git a/src/a.rs b/src/a.rs
1098what on earth is this line
1099--- a/src/a.rs
1100+++ b/src/a.rs
1101";
1102        let err = parse_patch(patch).unwrap_err();
1103        let PatchError::Unrecognised { line, .. } = err else {
1104            panic!("expected Unrecognised, got {err:?}");
1105        };
1106        assert!(line.contains("what on earth"));
1107    }
1108
1109    /// `diff -ru` emits several files with no `diff --git` headers at all.
1110    /// Treating the second `---` as belonging to the first file would drop
1111    /// every file but the last — a short change set, which is the one error
1112    /// this checker must not make.
1113    #[test]
1114    fn every_file_of_a_multi_file_plain_unified_diff_is_found() {
1115        let patch = "\
1116--- src/a.rs
1117+++ src/a.rs
1118@@ -1,1 +1,1 @@
1119-a
1120+A
1121--- deploy/production.yaml
1122+++ deploy/production.yaml
1123@@ -1,1 +1,1 @@
1124-replicas: 1
1125+replicas: 99
1126";
1127        let paths: Vec<String> = parse_patch(patch)
1128            .unwrap()
1129            .into_iter()
1130            .map(|c| c.path)
1131            .collect();
1132
1133        assert_eq!(paths, vec!["src/a.rs", "deploy/production.yaml"]);
1134    }
1135
1136    #[tokio::test]
1137    async fn a_second_file_of_a_plain_unified_diff_is_still_scope_checked() {
1138        let patch = "\
1139--- src/a.rs
1140+++ src/a.rs
1141@@ -1,1 +1,1 @@
1142-a
1143+A
1144--- deploy/production.yaml
1145+++ deploy/production.yaml
1146@@ -1,1 +1,1 @@
1147-replicas: 1
1148+replicas: 99
1149";
1150        let v = verdict_for(patch, &["fs:write:/src/**"]).await;
1151
1152        let Verdict::Fail(reason) = v else {
1153            panic!("expected Fail, got {v:?}");
1154        };
1155        assert!(reason.contains("deploy/production.yaml"), "{reason}");
1156    }
1157
1158    /// A header whose path cannot be read is refused, never skipped. Skipping
1159    /// leaves the change set short by exactly the file nobody could name.
1160    #[test]
1161    fn a_file_whose_path_cannot_be_read_is_refused_rather_than_dropped() {
1162        // A mode-only change to a path with a space: the `diff --git` line
1163        // cannot be split, and there are no `---`/`+++` lines to settle it.
1164        let patch = "\
1165diff --git a/deploy/my script.sh b/deploy/my script.sh
1166old mode 100644
1167new mode 100755
1168";
1169        assert!(
1170            matches!(parse_patch(patch), Err(PatchError::Ambiguous { .. })),
1171            "got {:?}",
1172            parse_patch(patch)
1173        );
1174    }
1175
1176    #[tokio::test]
1177    async fn an_unreadable_header_leaves_the_verdict_undecided_not_passing() {
1178        let patch = "\
1179diff --git a/deploy/my script.sh b/deploy/my script.sh
1180old mode 100644
1181new mode 100755
1182";
1183        let v = verdict_for(patch, &["fs:write:/src/**"]).await;
1184
1185        assert!(matches!(v, Verdict::Inconclusive(_)), "{v:?}");
1186        assert!(!v.is_pass(), "an unreadable patch must never pass");
1187    }
1188
1189    #[test]
1190    fn a_malformed_hunk_header_is_refused() {
1191        let patch = "\
1192diff --git a/src/a.rs b/src/a.rs
1193@@ not a range @@
1194";
1195        assert!(matches!(
1196            parse_patch(patch),
1197            Err(PatchError::MalformedHunk { .. })
1198        ));
1199    }
1200
1201    #[test]
1202    fn a_patch_that_ends_mid_hunk_is_refused() {
1203        let patch = "\
1204diff --git a/src/a.rs b/src/a.rs
1205@@ -1,9 +1,9 @@
1206 one line only
1207";
1208        assert!(matches!(
1209            parse_patch(patch),
1210            Err(PatchError::Truncated { .. })
1211        ));
1212    }
1213
1214    #[test]
1215    fn content_before_the_first_file_header_is_refused() {
1216        assert!(matches!(
1217            parse_patch("Subject: a commit message\n\nbody\n"),
1218            Err(PatchError::Unrecognised { .. })
1219        ));
1220    }
1221
1222    #[test]
1223    fn a_no_newline_marker_counts_against_neither_side() {
1224        let patch = "\
1225diff --git a/a b/a
1226--- a/a
1227+++ b/a
1228@@ -1,1 +1,1 @@
1229-old
1230\\ No newline at end of file
1231+new
1232\\ No newline at end of file
1233";
1234        assert_eq!(parse_patch(patch).unwrap().len(), 1);
1235    }
1236
1237    #[test]
1238    fn a_hunk_header_without_a_length_means_one_line() {
1239        let patch = "\
1240diff --git a/a b/a
1241--- a/a
1242+++ b/a
1243@@ -1 +1 @@
1244-old
1245+new
1246";
1247        assert_eq!(parse_patch(patch).unwrap().len(), 1);
1248    }
1249
1250    #[test]
1251    fn a_binary_change_is_recorded_without_parsing_its_body() {
1252        let patch = "\
1253diff --git a/logo.png b/logo.png
1254index 1111111..2222222 100644
1255GIT binary patch
1256literal 120
1257zcmeAS@N?(olHy`uVBq!ia0vp^
1258";
1259        let changes = parse_patch(patch).unwrap();
1260        assert_eq!(changes.len(), 1);
1261        assert!(changes[0].binary);
1262        assert_eq!(changes[0].path, "logo.png");
1263    }
1264
1265    #[test]
1266    fn a_timestamp_after_the_path_is_not_part_of_it() {
1267        let patch = "\
1268--- src/a.rs\t2026-01-01 10:00:00
1269+++ src/a.rs\t2026-01-02 10:00:00
1270@@ -1,1 +1,1 @@
1271-a
1272+b
1273";
1274        assert_eq!(parse_patch(patch).unwrap()[0].path, "src/a.rs");
1275    }
1276
1277    #[test]
1278    fn a_quoted_path_with_a_space_is_read_from_the_marker_lines() {
1279        let patch = "\
1280diff --git \"a/src/my file.rs\" \"b/src/my file.rs\"
1281--- \"a/src/my file.rs\"
1282+++ \"b/src/my file.rs\"
1283@@ -1,1 +1,1 @@
1284-a
1285+b
1286";
1287        assert_eq!(parse_patch(patch).unwrap()[0].path, "src/my file.rs");
1288    }
1289
1290    // ── change bookkeeping ──────────────────────────────────────────────────
1291
1292    #[test]
1293    fn a_rename_names_both_paths_and_a_plain_edit_names_one() {
1294        let renamed = Change {
1295            path: "b.rs".into(),
1296            from: Some("a.rs".into()),
1297            kind: ChangeKind::Renamed,
1298            old_mode: None,
1299            new_mode: None,
1300            binary: false,
1301        };
1302        assert_eq!(renamed.paths(), vec!["a.rs", "b.rs"]);
1303
1304        let edited = Change {
1305            from: Some("a.rs".into()),
1306            path: "a.rs".into(),
1307            ..renamed.clone()
1308        };
1309        assert_eq!(edited.paths(), vec!["a.rs"]);
1310    }
1311
1312    #[test]
1313    fn a_mode_change_is_only_a_change_when_the_modes_differ() {
1314        let same = Change {
1315            path: "a.sh".into(),
1316            from: None,
1317            kind: ChangeKind::Modified,
1318            old_mode: Some("100644".into()),
1319            new_mode: Some("100644".into()),
1320            binary: false,
1321        };
1322        assert!(!same.mode_changed());
1323
1324        let changed = Change {
1325            new_mode: Some("100755".into()),
1326            ..same
1327        };
1328        assert!(changed.mode_changed());
1329        assert!(changed.describe().contains("100644 -> 100755"));
1330    }
1331
1332    #[test]
1333    fn a_long_list_of_offenders_is_counted_rather_than_listed() {
1334        let paths: Vec<String> = (0..40).map(|n| format!("f{n}")).collect();
1335        let rendered = listing(&paths);
1336
1337        assert!(rendered.contains("f0"));
1338        assert!(rendered.contains("and 20 more"), "{rendered}");
1339    }
1340}