Skip to main content

speccoder_runtime/hooks/
gate.rs

1//! Gates at their point: each runs where the checks run, in the same
2//! sandbox, and only a clean exit 0 lets the point through. A gate that is
3//! not trusted, cannot run sandboxed, crashes, times out or changes a file
4//! the tick would commit blocks — never passes. Its words are for the
5//! person; nothing here reaches a model.
6
7use std::collections::BTreeMap;
8use std::path::{Path, PathBuf};
9use std::time::{Duration, Instant};
10
11use speccoder_core::hooks::{GateExit, GateOutcome, GateRecord, GateState, Point};
12
13use super::{HookRun, TrustState};
14use crate::sandbox::Runner;
15use crate::verify::process::{Invocation, OUTPUT_CAP};
16
17/// The words of a gate that waits for a person's trust.
18pub const AWAITS_TRUST: &str = "waits for your trust";
19/// The words of a gate whose bytes changed since they were trusted.
20pub const CHANGED: &str = "changed since it was trusted";
21
22/// Where a point stands: what a gate is told in `SPECCODER_*`.
23#[derive(Debug, Clone, Copy)]
24pub struct Facts<'a> {
25    /// The point.
26    pub point: Point,
27    /// The unit.
28    pub feature: &'a str,
29    /// The task, at a tick.
30    pub task: Option<&'a str>,
31    /// The branch to push, at a pull request.
32    pub branch: Option<&'a str>,
33}
34
35impl Facts<'_> {
36    fn what(&self) -> String {
37        match (self.point, self.task) {
38            (Point::Tick, Some(t)) => format!("tick {t}"),
39            (Point::Tick, None) => "tick".to_owned(),
40            (Point::Pr, _) => "pr".to_owned(),
41        }
42    }
43
44    fn env(&self) -> Vec<(String, String)> {
45        let mut v = vec![
46            ("SPECCODER_POINT".to_owned(), self.point.as_str().to_owned()),
47            ("SPECCODER_FEATURE".to_owned(), self.feature.to_owned()),
48        ];
49        if let Some(t) = self.task {
50            v.push(("SPECCODER_TASK".to_owned(), t.to_owned()));
51        }
52        if let Some(b) = self.branch {
53            v.push(("SPECCODER_BRANCH".to_owned(), b.to_owned()));
54        }
55        v
56    }
57}
58
59/// Runs the gates of `facts.point` declared in the configuration of the
60/// project at `worktree` (the project's folder in the worktree, where its
61/// checks run),
62/// in name order, through `runner` (the checks' runner); trust and runs are
63/// read from speccoder's state directory `state`. The first block stops the
64/// point; the gates after it are `not_run`. No gates: empty.
65#[must_use]
66pub fn run(worktree: &Path, runner: &Runner, state: &Path, facts: Facts<'_>) -> Vec<GateRecord> {
67    let hooks = match super::load(worktree) {
68        Ok(h) => h,
69        // A configuration no gate can be read from: the point does not pass.
70        Err(e) => {
71            return vec![GateRecord {
72                name: "hooks".to_owned(),
73                point: facts.point,
74                outcome: GateState::Blocked,
75                reason: Some(speccoder_core::hooks::clean(&e)),
76                code: Some("HOOK-MISCONFIGURED".to_owned()),
77                duration_ms: 0,
78                findings: Vec::new(),
79            }];
80        }
81    };
82    let gates: Vec<_> = hooks
83        .iter()
84        .filter(|h| h.at() == Some(facts.point))
85        .collect();
86    if gates.is_empty() {
87        return Vec::new();
88    }
89    let dir = super::dir(state, worktree);
90    let (runner, _) = runner.clone().carve(&[], &hidden_state(state, worktree));
91    let mut out = Vec::new();
92    for def in gates {
93        if out
94            .iter()
95            .any(|g: &GateRecord| g.outcome == GateState::Blocked)
96        {
97            out.push(GateRecord {
98                name: def.name.clone(),
99                point: facts.point,
100                outcome: GateState::NotRun,
101                reason: None,
102                code: None,
103                duration_ms: 0,
104                findings: Vec::new(),
105            });
106            super::record_run(&dir, &def.name, run_record(&facts, "not_run", 0, None));
107            continue;
108        }
109        out.push(judge(def, &runner, worktree, &dir, &facts));
110    }
111    out
112}
113
114/// One gate at its point: trusted and sandboxed, it runs; else it blocks.
115/// The run is recorded in its recent runs.
116fn judge(
117    def: &speccoder_core::hooks::HookDef,
118    runner: &Runner,
119    worktree: &Path,
120    dir: &Path,
121    facts: &Facts<'_>,
122) -> GateRecord {
123    let digested = super::digest(def, worktree);
124    let trust = digested.trust(dir);
125    let began = Instant::now();
126    let mut findings = Left::default();
127    let (outcome, code) = if trust == TrustState::Trusted {
128        // Only where the sandbox enforces what it hides: speccoder's state
129        // and the held-out tests stay out of a gate's reach.
130        let ended = if runner.hides() {
131            let (outcome, left) = run_one(def, runner, worktree, facts);
132            findings = left;
133            outcome
134        } else {
135            GateOutcome::Blocked(
136                "no sandbox is enforced here that hides speccoder's state, and a gate runs only in one"
137                    .to_owned(),
138            )
139        };
140        (ended, "HOOK-BLOCKED")
141    } else {
142        let why = match (&def.extension, trust == TrustState::Changed) {
143            (Some((ext, _)), true) => format!(
144                "{CHANGED}: the extension {ext} changed — trust it again (speccoder extensions trust {ext})"
145            ),
146            (Some((ext, _)), false) => {
147                format!("{AWAITS_TRUST} of the extension {ext} (speccoder extensions trust {ext})")
148            }
149            (None, true) => format!("{CHANGED}: trust it again (speccoder hooks trust)"),
150            (None, false) => format!("{AWAITS_TRUST} (speccoder hooks trust)"),
151        };
152        (GateOutcome::Blocked(why), "HOOK-UNTRUSTED")
153    };
154    let duration_ms = u64::try_from(began.elapsed().as_millis()).unwrap_or(u64::MAX);
155    let record = match outcome {
156        GateOutcome::Passed => GateRecord {
157            name: def.name.clone(),
158            point: facts.point,
159            outcome: GateState::Passed,
160            reason: None,
161            code: None,
162            duration_ms,
163            findings: Vec::new(),
164        },
165        GateOutcome::Blocked(reason) => GateRecord {
166            name: def.name.clone(),
167            point: facts.point,
168            outcome: GateState::Blocked,
169            // Findings say more than a checker's last line ("written: …").
170            reason: Some(summary_of(&findings.findings).unwrap_or(reason)),
171            code: Some(code.to_owned()),
172            duration_ms,
173            findings: findings.findings.clone(),
174        },
175    };
176    let mut run = run_record(
177        facts,
178        record.outcome.as_str(),
179        duration_ms,
180        record.reason.clone(),
181    );
182    run.findings = findings.findings;
183    run.findings_error = findings.error;
184    super::record_run(dir, &def.name, run);
185    record
186}
187
188/// The findings a gate left at `SPECCODER_FINDINGS`, or why they cannot
189/// be shown.
190#[derive(Debug, Default)]
191struct Left {
192    findings: Vec<speccoder_core::extensions::SarifFinding>,
193    error: Option<String>,
194}
195
196/// `speccoder hooks test`: the gate `def` alone, on `root`, as it would
197/// stand at its point.
198#[must_use]
199pub fn test(def: &speccoder_core::hooks::HookDef, root: &Path, state: &Path) -> GateRecord {
200    let point = def.at().unwrap_or(Point::Tick);
201    // The current feature, so a gate that reads its folder reads a real one.
202    let current = crate::format::open(root, None)
203        .ok()
204        .and_then(|o| o.snapshot.selection.feature);
205    let facts = Facts {
206        point,
207        feature: current.as_deref().unwrap_or("TEST"),
208        task: (point == Point::Tick).then_some("TEST"),
209        branch: (point == Point::Pr).then_some("TEST"),
210    };
211    let runner = match crate::sandbox::runner_at(root) {
212        Ok((r, _)) => r.carve(&[], &hidden_state(state, root)).0,
213        Err(e) => {
214            return GateRecord {
215                name: def.name.clone(),
216                point,
217                outcome: GateState::Blocked,
218                reason: Some(speccoder_core::hooks::clean(&e)),
219                code: Some("HOOK-BLOCKED".to_owned()),
220                duration_ms: 0,
221                findings: Vec::new(),
222            };
223        }
224    };
225    judge(def, &runner, root, &super::dir(state, root), &facts)
226}
227
228/// speccoder's state a gate may not read: other projects', and this one's
229/// journal, trust, approvals and held-out tests — everything but the
230/// worktrees (the gate runs in one) and the scratch directory.
231pub(crate) fn hidden_state(state: &Path, worktree: &Path) -> Vec<std::path::PathBuf> {
232    let project = crate::worktree::project_state_dir(state, worktree);
233    let entries = |d: &Path| -> Vec<std::path::PathBuf> {
234        std::fs::read_dir(d)
235            .into_iter()
236            .flatten()
237            .flatten()
238            .map(|e| e.path())
239            .collect()
240    };
241    let mut hidden: Vec<_> = entries(state)
242        .into_iter()
243        .filter(|p| *p != project)
244        .collect();
245    hidden.extend(entries(&project).into_iter().filter(|p| {
246        !matches!(
247            p.file_name().and_then(|n| n.to_str()),
248            Some("worktrees" | "tmp")
249        )
250    }));
251    hidden
252}
253
254fn run_record(
255    facts: &Facts<'_>,
256    outcome: &str,
257    duration_ms: u64,
258    error: Option<String>,
259) -> HookRun {
260    HookRun {
261        at: crate::attest::now_rfc3339(),
262        what: facts.what(),
263        outcome: outcome.to_owned(),
264        duration_ms,
265        error,
266        findings: Vec::new(),
267        findings_error: None,
268    }
269}
270
271fn run_one(
272    def: &speccoder_core::hooks::HookDef,
273    runner: &Runner,
274    worktree: &Path,
275    facts: &Facts<'_>,
276) -> (GateOutcome, Left) {
277    let Some(argv) = def.argv() else {
278        return (
279            GateOutcome::Blocked("a gate runs `argv`".to_owned()),
280            Left::default(),
281        );
282    };
283    // Where it may leave SARIF findings: the sandbox's scratch directory.
284    let findings_file = runner.scratch().map(|d| {
285        d.join(format!(
286            "findings-{}-{}.sarif",
287            std::process::id(),
288            std::time::SystemTime::now()
289                .duration_since(std::time::UNIX_EPOCH)
290                .map_or(0, |t| t.as_nanos())
291        ))
292    });
293    let findings_text = findings_file
294        .as_ref()
295        .map(|f| f.to_string_lossy().into_owned())
296        .unwrap_or_default();
297    let feature_dir = crate::extensions::feature_dir(worktree, facts.feature)
298        .map(|d| d.to_string_lossy().into_owned())
299        .unwrap_or_default();
300    let folder = def.extension.as_ref().map(|(_, f)| PathBuf::from(f));
301    let argv = crate::extensions::resolve(
302        argv,
303        folder.as_deref(),
304        &[
305            ("SPECCODER_FEATURE_DIR", feature_dir.clone()),
306            ("SPECCODER_FINDINGS", findings_text.clone()),
307        ],
308    );
309    let network = matches!(
310        def.role,
311        speccoder_core::hooks::Role::Gate { network: true, .. }
312    );
313    let mut env = crate::verify::process::scrubbed_env(std::env::vars());
314    // The names the hook asks for, even secret-looking ones: the person put
315    // them there.
316    for name in &def.env {
317        if let Ok(v) = std::env::var(name) {
318            env.retain(|(k, _)| k != name);
319            env.push((name.clone(), v));
320        }
321    }
322    env.extend(facts.env());
323    env.push(("SPECCODER_FEATURE_DIR".to_owned(), feature_dir));
324    // A Python checker leaves no bytecode beside its scripts.
325    env.push(("PYTHONDONTWRITEBYTECODE".to_owned(), "1".to_owned()));
326    if !findings_text.is_empty() {
327        env.push(("SPECCODER_FINDINGS".to_owned(), findings_text));
328    }
329    let inv = Invocation {
330        argv,
331        cwd: worktree.to_path_buf(),
332        env,
333        timeout: Duration::from_secs(def.timeout),
334        output_cap: OUTPUT_CAP,
335    };
336    let Some(before) = dirty(worktree) else {
337        return (
338            GateOutcome::Blocked("git could not say what the tick would commit".to_owned()),
339            Left::default(),
340        );
341    };
342    // No network unless the gate says `network = true`: not even loopback.
343    let ended = if network {
344        runner.run_with(&inv, true)
345    } else {
346        runner.run_offline(&inv)
347    };
348    let left = findings_file.map_or_else(Left::default, |f| read_findings(&f, worktree));
349    let Some(after) = dirty(worktree) else {
350        return (
351            GateOutcome::Blocked("git could not say what the tick would commit".to_owned()),
352            left,
353        );
354    };
355    let mut changed: Vec<String> = before
356        .keys()
357        .chain(after.keys())
358        .filter(|p| before.get(*p) != after.get(*p))
359        .cloned()
360        .collect();
361    changed.sort();
362    changed.dedup();
363    let stdout = String::from_utf8_lossy(&ended.stdout.kept);
364    let stderr = String::from_utf8_lossy(&ended.stderr.kept);
365    let outcome = GateOutcome::of(&GateExit {
366        exit_code: ended.exit_code,
367        timed_out: ended.timed_out,
368        spawn_error: ended.spawn_error.as_deref(),
369        stdout: &stdout,
370        stderr: &stderr,
371        changed: &changed,
372        timeout: def.timeout,
373    });
374    (outcome, left)
375}
376
377/// `22 findings, 22 errors — Requirement 'SR-001' has no task…`: what a
378/// gate's findings say, in one line; `None` without any.
379fn summary_of(findings: &[speccoder_core::extensions::SarifFinding]) -> Option<String> {
380    let first = findings
381        .iter()
382        .find(|f| f.level == "error")
383        .or_else(|| findings.first())?;
384    let errors = findings.iter().filter(|f| f.level == "error").count();
385    let count = if findings.len() == 1 {
386        "1 finding".to_owned()
387    } else {
388        format!("{} findings", findings.len())
389    };
390    let errors = match errors {
391        0 => String::new(),
392        1 => ", 1 error".to_owned(),
393        n => format!(", {n} errors"),
394    };
395    Some(speccoder_core::hooks::clean(&format!(
396        "{count}{errors} — {}",
397        first.message
398    )))
399}
400
401/// The SARIF a gate left in `file`, read once and removed: a regular file
402/// only (never through a link, never a device or a pipe), at most
403/// [`MAX_SARIF`](speccoder_core::extensions::MAX_SARIF) bytes read.
404fn read_findings(file: &Path, worktree: &Path) -> Left {
405    use std::io::Read as _;
406    let Ok(meta) = std::fs::symlink_metadata(file) else {
407        return Left::default();
408    };
409    let max = speccoder_core::extensions::MAX_SARIF;
410    let text = if !meta.file_type().is_file() {
411        Err("they are not a regular file".to_owned())
412    } else {
413        let mut buf = Vec::new();
414        std::fs::File::open(file)
415            .and_then(|f| f.take(max as u64 + 1).read_to_end(&mut buf))
416            .map_err(|e| e.to_string())
417            .and_then(|n| {
418                if n > max {
419                    Err(format!("the findings are more than {} KB", max / 1024))
420                } else {
421                    String::from_utf8(buf).map_err(|_| "they are not UTF-8".to_owned())
422                }
423            })
424    };
425    let _ = std::fs::remove_file(file);
426    let project = crate::extensions::project(worktree);
427    match text.and_then(|t| {
428        speccoder_core::extensions::sarif(&t, |p| crate::extensions::relative_to(&project, p))
429    }) {
430        Ok(findings) => Left {
431            findings,
432            error: None,
433        },
434        Err(e) => Left {
435            findings: Vec::new(),
436            error: Some(format!("its findings could not be shown: {e}")),
437        },
438    }
439}
440
441/// Every file git would commit that differs from `HEAD` (tracked changes
442/// and untracked files git does not ignore), with its content's hash.
443fn dirty(worktree: &Path) -> Option<BTreeMap<String, String>> {
444    let git = crate::git::Git::at(worktree);
445    let top = PathBuf::from(git.line(&["rev-parse", "--show-toplevel"]).ok()?.trim());
446    let listing = git.run(&["status", "--porcelain=v1", "-z", "-uall"]).ok()?;
447    let listing = String::from_utf8_lossy(&listing);
448    let mut out = BTreeMap::new();
449    let mut entries = listing.split('\0').filter(|e| !e.is_empty());
450    while let Some(e) = entries.next() {
451        if e.len() < 4 {
452            continue;
453        }
454        let (status, path) = e.split_at(3);
455        // A rename lists its source next; both count.
456        if status.starts_with('R') || status.starts_with('C') {
457            let _ = entries.next();
458        }
459        let hash = std::fs::read(top.join(path)).map_or_else(
460            |_| "deleted".to_owned(),
461            |bytes| {
462                use sha2::{Digest as _, Sha256};
463                Sha256::digest(&bytes)
464                    .iter()
465                    .map(|b| format!("{b:02x}"))
466                    .collect()
467            },
468        );
469        out.insert(path.to_owned(), hash);
470    }
471    Some(out)
472}