Skip to main content

kranz_engine/
contract_lint.rs

1//! Pure classification + synchronous base-tree runner for command-assertion
2//! linting at `approve_plan` time (M8 tier 1, feature f-1-1).
3//!
4//! At approval, each `check: command` assertion in the mission contract is
5//! run once against the untouched base tree so the operator learns whether
6//! it already passes (a polarity/vacuity SUSPECT — a correctly-scoped "the
7//! work landed" assertion must FAIL before the work lands; the abandoned
8//! m-0c885b mission shipped an `a6` grep that could only pass while the
9//! requirement was unmet) or fails as expected (the feature simply hasn't
10//! landed yet — the usual, benign case). This never blocks approval; it only
11//! surfaces information.
12//!
13//! This module takes plain inputs and does not depend on `MissionEngine`, so
14//! it is unit-testable in isolation (mirrors `contract_sweep.rs`).
15//!
16//! Approval is synchronous but normally called inside Tokio. Execution uses
17//! `command_exec`'s scoped-thread bridge into the same bounded, process-tree
18//! killed, sandbox-aware gate runner as validation/final gates; it never
19//! starts a nested runtime on the caller's runtime thread.
20
21use crate::types::{Assertion, AssertionCheck};
22use std::collections::HashMap;
23use std::path::Path;
24use std::time::{Duration, Instant};
25
26/// Per-command wall-clock timeout for the base-tree lint run.
27const PER_COMMAND_TIMEOUT: Duration = Duration::from_secs(600);
28
29/// Overall wall-clock budget for linting an entire contract's command
30/// assertions; once spent, remaining assertions are recorded `NotLinted`.
31const OVERALL_BUDGET: Duration = Duration::from_secs(180);
32
33/// Verdict for one `check: command` assertion run once against the
34/// untouched base tree.
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub enum AssertionLintOutcome {
37    /// The command already exits zero on the untouched base tree — a
38    /// SUSPECT: a correctly-scoped "the work landed" assertion should FAIL
39    /// before the work lands, so this may indicate a polarity/vacuity bug in
40    /// the assertion itself.
41    PassedOnBase,
42    /// The command exits non-zero on the untouched base tree — the usual,
43    /// benign case: the feature simply hasn't landed yet.
44    FailedOnBase,
45    /// The command hit the per-command timeout and never produced an exit
46    /// code — treated as a suspect since no verdict could be reached.
47    CouldNotVerdict,
48    /// Skipped because the overall wall-clock lint budget was already spent
49    /// before this assertion's turn.
50    NotLinted,
51}
52
53impl AssertionLintOutcome {
54    /// True for outcomes that warrant operator attention as a possible
55    /// author bug: already passing on base, or no verdict could be reached.
56    pub fn is_author_bug_suspect(&self) -> bool {
57        matches!(
58            self,
59            AssertionLintOutcome::PassedOnBase | AssertionLintOutcome::CouldNotVerdict
60        )
61    }
62}
63
64/// Lint result for a single command assertion.
65#[derive(Debug, Clone)]
66pub struct AssertionLint {
67    pub id: String,
68    pub command: String,
69    pub outcome: AssertionLintOutcome,
70    pub output_tail: String,
71}
72
73/// Full lint report for a contract's command assertions.
74#[derive(Debug, Clone)]
75pub struct ContractLintReport {
76    pub results: Vec<AssertionLint>,
77    /// Whether the tree was clean (no uncommitted changes) at the moment the
78    /// lint ran — when false, results may not reflect the pristine base.
79    pub tree_clean_at_base: bool,
80}
81
82impl ContractLintReport {
83    /// Results whose outcome is an author-bug suspect.
84    pub fn suspects(&self) -> Vec<&AssertionLint> {
85        self.results
86            .iter()
87            .filter(|r| r.outcome.is_author_bug_suspect())
88            .collect()
89    }
90
91    /// True when no command assertions were linted at all.
92    pub fn is_empty(&self) -> bool {
93        self.results.is_empty()
94    }
95
96    /// Compact operator-facing summary: suspects first under a clear label,
97    /// then base-expected-to-fail assertions under a separate label. When
98    /// `tree_clean_at_base` is false, prepends a note that the lint ran
99    /// against a dirty working tree.
100    pub fn summary(&self) -> String {
101        let mut parts: Vec<String> = Vec::new();
102
103        if !self.tree_clean_at_base {
104            parts.push(
105                "note: contract lint ran against a working tree with uncommitted changes; \
106                 results may not reflect the pristine base"
107                    .to_string(),
108            );
109        }
110
111        let suspects: Vec<&AssertionLint> = self.suspects();
112        if !suspects.is_empty() {
113            let list = suspects
114                .iter()
115                .map(|a| format!("[{}] {}", a.id, a.command))
116                .collect::<Vec<_>>()
117                .join("; ");
118            parts.push(format!(
119                "author-bug suspects (already pass / no verdict on the untouched base): {list}"
120            ));
121        }
122
123        let expected: Vec<&AssertionLint> = self
124            .results
125            .iter()
126            .filter(|r| r.outcome == AssertionLintOutcome::FailedOnBase)
127            .collect();
128        if !expected.is_empty() {
129            let list = expected
130                .iter()
131                .map(|a| format!("[{}] {}", a.id, a.command))
132                .collect::<Vec<_>>()
133                .join("; ");
134            parts.push(format!("base-expected-to-fail (benign): {list}"));
135        }
136
137        parts.join("\n")
138    }
139}
140
141/// Classify a completed (or timed-out) command run.
142///
143/// - `!ran_to_completion` → [`AssertionLintOutcome::CouldNotVerdict`]
144/// - `ran_to_completion && exited_success` → [`AssertionLintOutcome::PassedOnBase`]
145/// - `ran_to_completion && !exited_success` → [`AssertionLintOutcome::FailedOnBase`]
146///
147/// [`AssertionLintOutcome::NotLinted`] is assigned by the runner when the
148/// overall budget is spent, not by this function.
149pub fn classify(ran_to_completion: bool, exited_success: bool) -> AssertionLintOutcome {
150    if !ran_to_completion {
151        AssertionLintOutcome::CouldNotVerdict
152    } else if exited_success {
153        AssertionLintOutcome::PassedOnBase
154    } else {
155        AssertionLintOutcome::FailedOnBase
156    }
157}
158
159/// Environment for the base-tree lint run: the SAME cleared contract env
160/// the validation round and final gate use (agent-env-clear —
161/// [`crate::agent_env::contract_command_env`] over a per-process scratch
162/// HOME: minimal allowlist + `KRANZ_BASE_SHA` + toolchain caches + any
163/// `contractEnvPassthrough` names, ambient secrets cleared), plus
164/// git-hook-disabling keys so any `git` invoked by a contract command runs
165/// with hooks off. The lint is advisory-only, so a command that depended on
166/// a now-cleared ambient var flips to `FailedOnBase`/`CouldNotVerdict` —
167/// exactly the signal that it needs a `contractEnvPassthrough` entry.
168pub fn lint_env(
169    scratch: &Path,
170    base_sha: Option<&str>,
171    passthrough: &[String],
172) -> HashMap<String, String> {
173    let mut env = crate::agent_env::contract_command_env(scratch, base_sha, passthrough);
174    env.insert("GIT_CONFIG_COUNT".to_string(), "1".to_string());
175    env.insert("GIT_CONFIG_KEY_0".to_string(), "core.hooksPath".to_string());
176    env.insert("GIT_CONFIG_VALUE_0".to_string(), "/dev/null".to_string());
177    env
178}
179
180/// Lint every `check: command` assertion in `contract` against the
181/// untouched base tree, using the module's default per-command timeout and
182/// overall budget. `passthrough` is the mission config's
183/// `contractEnvPassthrough` (see [`lint_env`]).
184pub(crate) fn run_contract_lint(
185    cwd: &Path,
186    scratch: &Path,
187    base_sha: Option<&str>,
188    contract: &[Assertion],
189    tree_clean_at_base: bool,
190    passthrough: &[String],
191    sandbox: &crate::command_exec::GateSandbox,
192) -> ContractLintReport {
193    run_contract_lint_with_limits(
194        cwd,
195        scratch,
196        base_sha,
197        contract,
198        tree_clean_at_base,
199        PER_COMMAND_TIMEOUT,
200        OVERALL_BUDGET,
201        passthrough,
202        sandbox,
203    )
204}
205
206/// Same as [`run_contract_lint`] but with injectable `per_command` timeout
207/// and `overall` budget, so tests can exercise timeout/budget behavior
208/// without waiting on the production defaults.
209#[allow(clippy::too_many_arguments)]
210pub(crate) fn run_contract_lint_with_limits(
211    cwd: &Path,
212    scratch: &Path,
213    base_sha: Option<&str>,
214    contract: &[Assertion],
215    tree_clean_at_base: bool,
216    per_command: Duration,
217    overall: Duration,
218    passthrough: &[String],
219    sandbox: &crate::command_exec::GateSandbox,
220) -> ContractLintReport {
221    let env = lint_env(scratch, base_sha, passthrough);
222    let overall_start = Instant::now();
223    let mut results = Vec::new();
224
225    for assertion in contract {
226        if assertion.check != AssertionCheck::Command {
227            continue;
228        }
229        let Some(command) = assertion.command.as_deref() else {
230            continue;
231        };
232
233        if overall_start.elapsed() >= overall {
234            results.push(AssertionLint {
235                id: assertion.id.clone(),
236                command: command.to_string(),
237                outcome: AssertionLintOutcome::NotLinted,
238                output_tail: String::new(),
239            });
240            continue;
241        }
242
243        let (code, output_tail) = crate::command_exec::run_shell_command_sandboxed_blocking(
244            cwd,
245            command,
246            per_command,
247            &env,
248            sandbox,
249        );
250        let outcome = classify(code.is_some(), code == Some(0));
251        results.push(AssertionLint {
252            id: assertion.id.clone(),
253            command: command.to_string(),
254            outcome,
255            output_tail,
256        });
257    }
258
259    ContractLintReport {
260        results,
261        tree_clean_at_base,
262    }
263}
264
265#[cfg(test)]
266mod tests {
267    use super::*;
268    use crate::test_shell::{sleep_millis, FAIL, SUCCEED};
269
270    fn run_lint(cwd: &Path, base_sha: Option<&str>, contract: &[Assertion]) -> ContractLintReport {
271        let scratch = tempfile::tempdir().unwrap();
272        run_contract_lint(
273            cwd,
274            scratch.path(),
275            base_sha,
276            contract,
277            true,
278            &[],
279            &crate::command_exec::GateSandbox::Disabled,
280        )
281    }
282
283    fn command_assertion(id: &str, command: &str) -> Assertion {
284        Assertion {
285            id: id.to_string(),
286            statement: format!("statement for {id}"),
287            check: AssertionCheck::Command,
288            command: Some(command.to_string()),
289            negative_control: None,
290            pty_script: None,
291        }
292    }
293
294    fn judgement_assertion(id: &str) -> Assertion {
295        Assertion {
296            id: id.to_string(),
297            statement: format!("statement for {id}"),
298            check: AssertionCheck::AgentJudgement,
299            command: None,
300            negative_control: None,
301            pty_script: None,
302        }
303    }
304
305    #[test]
306    fn approval_lint_summary_notes_dirty_tree_at_base() {
307        // finding f-1-2: when the lint ran against a working tree that was
308        // not clean at base, `summary()` must prepend a note saying so —
309        // asserted directly on the struct rather than only by inspection.
310        let dirty = ContractLintReport {
311            results: vec![AssertionLint {
312                id: "a1".to_string(),
313                command: "true".to_string(),
314                outcome: AssertionLintOutcome::PassedOnBase,
315                output_tail: String::new(),
316            }],
317            tree_clean_at_base: false,
318        };
319        let summary = dirty.summary();
320        assert!(
321            summary.contains(
322                "note: contract lint ran against a working tree with uncommitted changes"
323            ),
324            "{summary}"
325        );
326
327        let clean = ContractLintReport {
328            tree_clean_at_base: true,
329            ..dirty
330        };
331        assert!(
332            !clean.summary().contains("uncommitted changes"),
333            "{}",
334            clean.summary()
335        );
336    }
337
338    #[test]
339    fn approval_lint_classify_polarity() {
340        assert_eq!(classify(true, true), AssertionLintOutcome::PassedOnBase);
341        assert_eq!(classify(true, false), AssertionLintOutcome::FailedOnBase);
342        assert_eq!(classify(false, true), AssertionLintOutcome::CouldNotVerdict);
343        assert_eq!(
344            classify(false, false),
345            AssertionLintOutcome::CouldNotVerdict
346        );
347
348        assert!(AssertionLintOutcome::PassedOnBase.is_author_bug_suspect());
349        assert!(AssertionLintOutcome::CouldNotVerdict.is_author_bug_suspect());
350        assert!(!AssertionLintOutcome::FailedOnBase.is_author_bug_suspect());
351        assert!(!AssertionLintOutcome::NotLinted.is_author_bug_suspect());
352    }
353
354    #[test]
355    fn approval_lint_env_has_base_sha_and_disables_hooks() {
356        let scratch = tempfile::tempdir().unwrap();
357        let with_sha = lint_env(scratch.path(), Some("deadbeef"), &[]);
358        assert_eq!(
359            with_sha.get("KRANZ_BASE_SHA").map(String::as_str),
360            Some("deadbeef")
361        );
362        assert_eq!(
363            with_sha.get("GIT_CONFIG_COUNT").map(String::as_str),
364            Some("1")
365        );
366        assert_eq!(
367            with_sha.get("GIT_CONFIG_KEY_0").map(String::as_str),
368            Some("core.hooksPath")
369        );
370        assert_eq!(
371            with_sha.get("GIT_CONFIG_VALUE_0").map(String::as_str),
372            Some("/dev/null")
373        );
374
375        let without_sha = lint_env(scratch.path(), None, &[]);
376        assert!(!without_sha.contains_key("KRANZ_BASE_SHA"));
377        assert_eq!(
378            without_sha.get("GIT_CONFIG_COUNT").map(String::as_str),
379            Some("1")
380        );
381        assert_eq!(
382            without_sha.get("GIT_CONFIG_KEY_0").map(String::as_str),
383            Some("core.hooksPath")
384        );
385        assert_eq!(
386            without_sha.get("GIT_CONFIG_VALUE_0").map(String::as_str),
387            Some("/dev/null")
388        );
389    }
390
391    #[test]
392    fn approval_lint_runner_buckets_true_false() {
393        let contract = vec![
394            command_assertion("a1", SUCCEED),
395            command_assertion("a2", FAIL),
396            judgement_assertion("a3"),
397        ];
398        let report = run_lint(&std::env::temp_dir(), None, &contract);
399
400        assert_eq!(report.results.len(), 2);
401
402        let a1 = report.results.iter().find(|r| r.id == "a1").unwrap();
403        assert_eq!(a1.outcome, AssertionLintOutcome::PassedOnBase);
404
405        let a2 = report.results.iter().find(|r| r.id == "a2").unwrap();
406        assert_eq!(a2.outcome, AssertionLintOutcome::FailedOnBase);
407
408        let suspects = report.suspects();
409        assert_eq!(suspects.len(), 1);
410        assert_eq!(suspects[0].id, "a1");
411    }
412
413    #[test]
414    fn approval_lint_runner_times_out_slow_command() {
415        let contract = vec![command_assertion("a1", &sleep_millis(5_000))];
416        let scratch = tempfile::tempdir().unwrap();
417        let start = Instant::now();
418        let report = run_contract_lint_with_limits(
419            &std::env::temp_dir(),
420            scratch.path(),
421            None,
422            &contract,
423            true,
424            Duration::from_millis(200),
425            Duration::from_secs(600),
426            &[],
427            &crate::command_exec::GateSandbox::Disabled,
428        );
429        let elapsed = start.elapsed();
430
431        assert!(
432            elapsed < Duration::from_secs(2),
433            "runner should not wait for the full sleep, took {elapsed:?}"
434        );
435        assert_eq!(report.results.len(), 1);
436        assert_eq!(
437            report.results[0].outcome,
438            AssertionLintOutcome::CouldNotVerdict
439        );
440        assert!(report.results[0].outcome.is_author_bug_suspect());
441    }
442
443    #[test]
444    fn approval_lint_runner_budget_skips_remainder() {
445        let contract = vec![
446            command_assertion("a1", &sleep_millis(200)),
447            command_assertion("a2", SUCCEED),
448            command_assertion("a3", FAIL),
449        ];
450        let scratch = tempfile::tempdir().unwrap();
451        let report = run_contract_lint_with_limits(
452            &std::env::temp_dir(),
453            scratch.path(),
454            None,
455            &contract,
456            true,
457            Duration::from_secs(600),
458            Duration::from_millis(50),
459            &[],
460            &crate::command_exec::GateSandbox::Disabled,
461        );
462
463        assert_eq!(report.results.len(), 3);
464        let a2 = report.results.iter().find(|r| r.id == "a2").unwrap();
465        let a3 = report.results.iter().find(|r| r.id == "a3").unwrap();
466        assert_eq!(a2.outcome, AssertionLintOutcome::NotLinted);
467        assert_eq!(a3.outcome, AssertionLintOutcome::NotLinted);
468        assert!(!a2.outcome.is_author_bug_suspect());
469        assert!(!a3.outcome.is_author_bug_suspect());
470    }
471
472    #[test]
473    fn approval_lint_runner_flags_inverted_lockfile_grep_shape() {
474        // Reproduces the concrete m-0c885b `a6` bug shape (research.md:24,
475        // reconstructed from `git show 65d3201:.kranz/missions/m-8b3ec3/plan.json`):
476        // `grep -L <old-pin> Cargo.lock`, authored to assert "the old
477        // dependency pin is gone (the upgrade landed)". Both GNU and BSD
478        // grep base the exit status on whether the PATTERN matched
479        // anywhere, not on whether a filename was printed by `-L` — so this
480        // command exits 0 (success) exactly when the old pin is STILL
481        // PRESENT, which is the pre-upgrade / untouched-base state. That is
482        // the inverted-polarity bug: it already passes before the work
483        // lands. Run it for real, against this repo's own Cargo.lock,
484        // targeting a dependency ("tokio") that is genuinely present on the
485        // untouched base, to prove the lint flags this exact shape as a
486        // suspect rather than relying on `true`/`false` proxies.
487        // This assertion is ABOUT POSIX grep's exit-status semantics, so a
488        // portable rewrite would test something else and `findstr` has no
489        // equivalent of `-L`. Windows ships no grep; CI and any box with Git's
490        // `usr/bin` on PATH have one, so run it there and say so plainly when
491        // there is nothing to run against rather than failing a stock box.
492        if !crate::sandbox::command_available("grep") {
493            crate::test_capability::skip(
494                crate::test_capability::capability::GREP,
495                "no grep on PATH; the inverted-polarity shape is unexercised here",
496            );
497            return;
498        }
499        let repo_root = Path::new(env!("CARGO_MANIFEST_DIR"))
500            .parent()
501            .and_then(Path::parent)
502            .expect("crates/engine has a workspace root two levels up")
503            .to_path_buf();
504        assert!(
505            repo_root.join("Cargo.lock").is_file(),
506            "expected {:?} to contain Cargo.lock",
507            repo_root
508        );
509
510        let contract = vec![command_assertion(
511            "a6",
512            r#"grep -L '^name = "tokio"' Cargo.lock"#,
513        )];
514        let report = run_lint(&repo_root, None, &contract);
515
516        assert_eq!(report.results.len(), 1);
517        let a6 = &report.results[0];
518        assert_eq!(a6.outcome, AssertionLintOutcome::PassedOnBase);
519        assert!(a6.outcome.is_author_bug_suspect());
520
521        let suspects = report.suspects();
522        assert_eq!(suspects.len(), 1);
523        assert_eq!(suspects[0].id, "a6");
524    }
525
526    #[tokio::test]
527    async fn approval_lint_runner_safe_under_tokio() {
528        let contract = vec![
529            command_assertion("a1", SUCCEED),
530            command_assertion("a2", FAIL),
531        ];
532        let report = run_lint(&std::env::temp_dir(), None, &contract);
533        assert_eq!(report.results.len(), 2);
534    }
535
536    /// agent-env-clear: the approval-time lint executes the SAME
537    /// model-drafted contract commands as the final gate, so it runs them
538    /// in the same cleared env — a poisoned ambient secret must be
539    /// invisible to the linted command.
540    #[cfg(unix)]
541    #[test]
542    fn approval_lint_command_cannot_see_ambient_secrets() {
543        let _poison = crate::agent_env::EnvTestGuard::engage(&[("GH_TOKEN", "hunter2-lint")]);
544
545        let contract = vec![command_assertion(
546            "a1",
547            "test -z \"$GH_TOKEN\" && env | grep -c hunter2-lint | grep -q '^0$'",
548        )];
549        let report = run_lint(&std::env::temp_dir(), None, &contract);
550
551        assert_eq!(report.results.len(), 1);
552        assert_eq!(
553            report.results[0].outcome,
554            AssertionLintOutcome::PassedOnBase,
555            "the poisoned ambient GH_TOKEN must be cleared from the lint env: {}",
556            report.results[0].output_tail
557        );
558    }
559}