vetto 0.3.5

Daemon-less sandbox + security layer for AI coding agents (Landlock/Seatbelt, TUI statusline, post-session audit reports)
Documentation
//! Gate report + exit-code contract (FM-10/FM-12).
//!
//! The gate blocks promotion unless ALL hold:
//!
//! - No blocker-category FAIL or INCONCLUSIVE.
//! - Zero INCONCLUSIVE in blocker categories (fail-closed).
//! - Every NOT_APPLICABLE carries absence evidence (no silent skips).
//! - Canary scenarios (proof-of-enforcement-alive) all PASS.
//! - Per-category PASS minimums met (no vacuum PASS: FM-12).
//!
//! Exit codes reuse `crate::exit_codes`: 0 clean, 1 gate failure (leak or
//! inconclusive-in-blocker), 125 harness fail-closed (could not run).

use std::collections::{BTreeMap, BTreeSet};

use serde::{Deserialize, Serialize};

use super::model::{Category, ScenarioResult, Verdict};

/// Canary scenario ids: proof the enforcement under test is alive.
/// A green gate with a failing canary is a contradiction -> gate FAIL.
pub const CANARY_IDS: &[&str] = &["VFS-TRAV-001", "ENV-LEAK-001", "PROC-ESC-001"];

/// Required blocker categories mapping to the security invariants I1..I6.
pub const BLOCKER_CATEGORIES: &[Category] = &[
    Category::Spawn,
    Category::FsRead,
    Category::FsWrite,
    Category::Net,
    Category::Proc,
    Category::Secrets,
];

/// Minimum PASS counts per blocker category for a meaningful gate.
pub fn min_pass_per_category() -> BTreeMap<Category, usize> {
    BTreeMap::from([
        (Category::FsRead, 1),
        (Category::FsWrite, 1),
        (Category::Net, 1),
        (Category::Proc, 1),
        (Category::Secrets, 1),
        (Category::Spawn, 1),
    ])
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GateReport {
    pub status: String,
    pub passed: usize,
    pub failed: usize,
    pub inconclusive: usize,
    pub not_applicable: usize,
    pub blocking: Vec<String>,
    pub results: Vec<ScenarioResult>,
    /// Machine-readable strength accounting (Blocker 4): ids of PASS
    /// verdicts on PARTIAL claims. A PARTIAL PASS keeps its verdict (axes
    /// stay orthogonal) but is never silently equal to a STRONG PASS in
    /// machine output.
    #[serde(default)]
    pub partial_pass: Vec<String>,
    /// Ids of PASS verdicts on UNSUPPORTED claims. Must always be empty in
    /// practice (the oracle ceiling demotes them before the gate); any
    /// entry here fails the gate outright as defense in depth, so an
    /// UNSUPPORTED claim can never masquerade as a valid PASS.
    #[serde(default)]
    pub unsupported_pass: Vec<String>,
}

impl GateReport {
    pub fn summary(&self) -> String {
        format!(
            "verify-ng gate: {} (pass={} fail={} inconclusive={} n/a={})",
            self.status, self.passed, self.failed, self.inconclusive, self.not_applicable
        )
    }
}

/// Evaluate the gate. `na_evidence` maps scenario id -> absence evidence
/// strings for NOT_APPLICABLE results; entries missing evidence fail the gate.
pub fn evaluate_gate(
    results: &[ScenarioResult],
    na_evidence: &BTreeMap<String, Vec<String>>,
    registry_hash: &str,
) -> GateReport {
    let mut blocking = Vec::new();
    let mut passed = 0;
    let mut failed = 0;
    let mut inconclusive = 0;
    let mut not_applicable = 0;
    let mut pass_per_category: BTreeMap<Category, usize> = BTreeMap::new();
    let mut seen: BTreeSet<&str> = BTreeSet::new();
    let mut partial_pass = Vec::new();
    let mut unsupported_pass = Vec::new();

    // Master Task Section 13: Empty verification suite cannot PASS.
    if results.is_empty() {
        blocking.push("suite:empty-verification-suite".to_string());
    }

    for r in results {
        match r.verdict {
            Verdict::Pass => {
                passed += 1;
                *pass_per_category.entry(r.category).or_insert(0) += 1;
                // Strength accounting: a PASS on a weak claim keeps its
                // verdict (orthogonal axes) but is recorded explicitly so
                // machine consumers never confuse it with a STRONG PASS.
                match r.strength {
                    super::model::ClaimStrength::Partial => {
                        partial_pass.push(r.id.clone());
                    }
                    super::model::ClaimStrength::Unsupported => {
                        unsupported_pass.push(r.id.clone());
                    }
                    super::model::ClaimStrength::Strong => {}
                }
            }
            Verdict::Fail => failed += 1,
            Verdict::Inconclusive => inconclusive += 1,
            Verdict::NotApplicable => not_applicable += 1,
        }
        if r.blocks_release() {
            blocking.push(r.id.clone());
        }
        if r.verdict == Verdict::NotApplicable {
            let has_evidence = na_evidence
                .get(&r.id)
                .map(|e| e.iter().any(|item| !item.trim().is_empty()))
                .unwrap_or(false);
            if !has_evidence {
                blocking.push(format!("{}:N/A-without-evidence", r.id));
            }
        }
        seen.insert(r.id.as_str());
    }

    // Master Task Section 13: Missing required blocker category is not success.
    for category in BLOCKER_CATEGORIES {
        let has_category = results.iter().any(|r| r.category == *category);
        if !has_category {
            blocking.push(format!("{}:missing-blocker-category", category.label()));
        }
    }

    // Canary rule: every canary that ran must PASS; a canary that did not
    // run at all also fails the gate (no vacuum PASS).
    for canary in CANARY_IDS {
        match results.iter().find(|r| &r.id == canary) {
            Some(r) if r.verdict == Verdict::Pass => {}
            Some(r) => blocking.push(format!("{canary}:canary-{:?}", r.verdict)),
            None => blocking.push(format!("{canary}:canary-missing")),
        }
    }

    // Per-category PASS minimums.
    for (category, min) in min_pass_per_category() {
        let got = pass_per_category.get(&category).copied().unwrap_or(0);
        if got < min {
            blocking.push(format!("{}:only-{got}-pass-min-{min}", category.label()));
        }
    }

    // Zero INCONCLUSIVE in blockers (fail-closed): already covered by
    // blocks_release, but stated explicitly for the report.
    // Defense in depth behind the oracle ceiling: a PASS on an UNSUPPORTED
    // claim must never make the suite green even if a judging bug let one
    // through (the oracle demotes such verdicts first; the gate refuses
    // them independently).
    for id in &unsupported_pass {
        blocking.push(format!("{id}:unsupported-pass"));
    }
    let status = if blocking.is_empty() {
        "pass"
    } else {
        "failed"
    };
    let _ = registry_hash;
    GateReport {
        status: status.to_string(),
        passed,
        failed,
        inconclusive,
        not_applicable,
        blocking,
        results: results.to_vec(),
        partial_pass,
        unsupported_pass,
    }
}

/// CLI exit code for a gate report: 0 pass, 1 gate failure.
pub fn gate_exit_code(report: &GateReport) -> i32 {
    if report.status == "pass" {
        0
    } else {
        1
    }
}

#[cfg(test)]
mod exit_tests {
    use super::*;
    use crate::verify_ng::model::ClaimStrength;

    fn result(id: &str, category: Category, verdict: Verdict) -> ScenarioResult {
        ScenarioResult {
            id: id.to_string(),
            category,
            strength: ClaimStrength::Strong,
            verdict,
            detail: String::new(),
        }
    }

    fn full_pass_set() -> Vec<ScenarioResult> {
        vec![
            result("VFS-TRAV-001", Category::FsRead, Verdict::Pass),
            result("VFS-W-1", Category::FsWrite, Verdict::Pass),
            result("NET-DNS-IPV6-001", Category::Net, Verdict::Pass),
            result("PROC-ESC-001", Category::Proc, Verdict::Pass),
            result("ENV-LEAK-001", Category::Secrets, Verdict::Pass),
            result("RACE-BINDING-001", Category::Spawn, Verdict::Pass),
        ]
    }

    /// FM-12 GATE-VACUUM-001: empty suite must FAIL the gate, never pass.
    #[test]
    fn gate_vacuum_001_empty_suite_fails() {
        let report = evaluate_gate(&[], &BTreeMap::new(), "reg");
        assert_eq!(report.status, "failed");
        assert_eq!(gate_exit_code(&report), 1);
        assert!(report.blocking.iter().any(|b| b.contains("canary-missing")));
    }

    /// All-N/A suite must FAIL (missing canaries + minimums).
    #[test]
    fn all_na_suite_fails() {
        let results = vec![
            result("VFS-TRAV-001", Category::FsRead, Verdict::NotApplicable),
            result("ENV-LEAK-001", Category::Secrets, Verdict::NotApplicable),
        ];
        let mut ev = BTreeMap::new();
        ev.insert(
            "VFS-TRAV-001".to_string(),
            vec!["netns: absent (x)".to_string()],
        );
        ev.insert(
            "ENV-LEAK-001".to_string(),
            vec!["spawn: absent (y)".to_string()],
        );
        let report = evaluate_gate(&results, &ev, "reg");
        assert_eq!(report.status, "failed");
    }

    /// N/A without absence evidence blocks even a passing suite.
    #[test]
    fn na_without_evidence_blocks() {
        let mut results = full_pass_set();
        results.push(result(
            "WIN-WSL-001",
            Category::FsRead,
            Verdict::NotApplicable,
        ));
        let report = evaluate_gate(&results, &BTreeMap::new(), "reg");
        assert_eq!(report.status, "failed");
        assert!(report
            .blocking
            .iter()
            .any(|b| b.contains("N/A-without-evidence")));
    }

    /// Full PASS set with N/A evidence passes.
    #[test]
    fn full_pass_set_passes() {
        let mut results = full_pass_set();
        results.push(result("WIN-WSL-001", Category::Aux, Verdict::NotApplicable));
        let mut ev = BTreeMap::new();
        ev.insert(
            "WIN-WSL-001".to_string(),
            vec!["wsl: absent (unmapped)".to_string()],
        );
        let report = evaluate_gate(&results, &ev, "reg");
        assert_eq!(report.status, "pass");
        assert_eq!(gate_exit_code(&report), 0);
    }

    /// One INCONCLUSIVE in a blocker fails the gate.
    #[test]
    fn inconclusive_in_blocker_fails() {
        let mut results = full_pass_set();
        results[2] = result("NET-DNS-IPV6-001", Category::Net, Verdict::Inconclusive);
        let report = evaluate_gate(&results, &BTreeMap::new(), "reg");
        assert_eq!(report.status, "failed");
    }

    /// Master Task Section 13: missing any required blocker category (I1..I6) fails the gate.
    #[test]
    fn missing_required_blocker_category_fails_gate() {
        for category in BLOCKER_CATEGORIES {
            let mut results = full_pass_set();
            results.retain(|r| r.category != *category);
            let report = evaluate_gate(&results, &BTreeMap::new(), "reg");
            assert_eq!(
                report.status,
                "failed",
                "gate must fail when blocker category {} is missing",
                category.label()
            );
            assert!(
                report
                    .blocking
                    .iter()
                    .any(|b| b.contains(&format!("{}:missing-blocker-category", category.label()))),
                "blocking must record missing-blocker-category for {}",
                category.label()
            );
        }
    }

    /// N/A with whitespace-only or empty strings must FAIL as N/A-without-evidence.
    #[test]
    fn na_with_blank_evidence_blocks() {
        let mut results = full_pass_set();
        results.push(result("WIN-WSL-001", Category::Aux, Verdict::NotApplicable));
        let mut ev = BTreeMap::new();
        ev.insert(
            "WIN-WSL-001".to_string(),
            vec!["   ".to_string(), "".to_string()],
        );
        let report = evaluate_gate(&results, &ev, "reg");
        assert_eq!(report.status, "failed");
        assert!(report
            .blocking
            .iter()
            .any(|b| b.contains("N/A-without-evidence")));
    }

    /// Zero inconclusive in blockers (I1..I6) rule: any inconclusive in I1..I6 fails gate,
    /// but inconclusive in Category::Aux does not block release.
    #[test]
    fn zero_inconclusive_i1_to_i6_rule_enforced() {
        for category in BLOCKER_CATEGORIES {
            let mut results = full_pass_set();
            results.push(result("EXTRA-BLOCKER", *category, Verdict::Inconclusive));
            let report = evaluate_gate(&results, &BTreeMap::new(), "reg");
            assert_eq!(
                report.status,
                "failed",
                "inconclusive in {} must fail the gate",
                category.label()
            );
            assert!(report.blocking.iter().any(|b| b == "EXTRA-BLOCKER"));
        }

        // Inconclusive in Aux does NOT block release
        let mut results = full_pass_set();
        results.push(result("AUX-OPTIONAL", Category::Aux, Verdict::Inconclusive));
        let report = evaluate_gate(&results, &BTreeMap::new(), "reg");
        assert_eq!(report.status, "pass");
        assert_eq!(gate_exit_code(&report), 0);
    }
}