Skip to main content

lean_ctx/core/policy/
coverage.rs

1//! CGB coverage — automated *partial* assessment of a resolved policy pack
2//! against the Context Governance Benchmark v1.0-draft (GL #426).
3//!
4//! Honesty contract: a static pack analysis can only ever produce **partial
5//! evidence** for a handful of controls. This module therefore (a) checks
6//! redaction patterns against real synthetic fixtures instead of trusting
7//! pattern names, (b) reports `Inconclusive` — never `Pass` — when a pack
8//! simply doesn't state something, and (c) refuses to compute a maturity
9//! grade: grades require the full manual assessment
10//! (`assessment/TEMPLATE.md` in the spec repo).
11
12use regex::Regex;
13use serde::Serialize;
14
15use super::ResolvedPolicy;
16
17/// Spec version the checks below were written against.
18pub const BENCHMARK_ID: &str = "cgb-v1.0-draft";
19/// Total controls in the spec — denominator for the honesty line.
20pub const CONTROLS_TOTAL: usize = 32;
21
22/// Outcome of one automated check.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
24#[serde(rename_all = "lowercase")]
25pub enum CheckStatus {
26    /// The pack provides positive evidence for this aspect.
27    Pass,
28    /// The pack contradicts the control's expectation.
29    Fail,
30    /// The pack is silent — the aspect must be verified elsewhere.
31    Inconclusive,
32}
33
34/// One automated check over one control aspect.
35#[derive(Debug, Clone, Serialize)]
36pub struct CoverageCheck {
37    /// Control ID, e.g. `CGB-1.1`.
38    pub control: &'static str,
39    /// Short control title (spec wording, abbreviated).
40    pub title: &'static str,
41    pub status: CheckStatus,
42    /// What was observed, in one line.
43    pub detail: String,
44}
45
46/// Synthetic credential fixtures for CGB-1.1 — one per credential class the
47/// control names. A class counts as covered when any resolved redaction
48/// pattern matches its fixture. Shared with the framework compliance
49/// reports (GL #424) so CGB and framework claims test identical fixtures.
50pub(crate) const CREDENTIAL_FIXTURES: &[(&str, &str)] = &[
51    ("private key block", "-----BEGIN RSA PRIVATE KEY-----"),
52    ("cloud access key", "AKIAIOSFODNN7EXAMPLE"),
53    (
54        "credential assignment",
55        "api_key = \"sk-supersecretvalue1234\"",
56    ),
57    (
58        "bearer token",
59        "Authorization: Bearer abcdefghij0123456789xyz",
60    ),
61];
62
63/// Synthetic non-credential sensitivity fixtures for CGB-1.3 (regulated
64/// identifiers). Matching ≥ 1 demonstrates classification beyond secrets.
65/// Shared with the framework compliance reports (GL #424).
66pub(crate) const DOMAIN_FIXTURES: &[(&str, &str)] = &[
67    ("IBAN", "DE89 3704 0044 0532 0130 00"),
68    ("payment card", "4111 1111 1111 1111"),
69    ("US SSN", "SSN: 123-45-6789"),
70    ("date of birth", "DOB: 03/14/1975"),
71];
72
73/// Tool-name fragments that indicate network egress beyond the model
74/// provider (CGB-5.5 aspect: egress deniable by policy).
75const EGRESS_TOOL_HINTS: &[&str] = &["url", "web", "fetch", "http"];
76
77/// Run all automated checks against a resolved policy.
78pub fn assess(policy: &ResolvedPolicy) -> Vec<CoverageCheck> {
79    let patterns: Vec<(String, Regex)> = policy
80        .redaction
81        .iter()
82        .filter_map(|(name, raw)| Regex::new(raw).ok().map(|re| (name.clone(), re)))
83        .collect();
84
85    let matches = |fixture: &str| patterns.iter().any(|(_, re)| re.is_match(fixture));
86
87    let mut checks = Vec::new();
88
89    // CGB-1.1 — credential material never reaches the model.
90    let missing: Vec<&str> = CREDENTIAL_FIXTURES
91        .iter()
92        .filter(|(_, fixture)| !matches(fixture))
93        .map(|(class, _)| *class)
94        .collect();
95    checks.push(if missing.is_empty() {
96        CoverageCheck {
97            control: "CGB-1.1",
98            title: "credential redaction",
99            status: CheckStatus::Pass,
100            detail: format!(
101                "{}/{} credential fixture classes matched by redaction patterns",
102                CREDENTIAL_FIXTURES.len(),
103                CREDENTIAL_FIXTURES.len()
104            ),
105        }
106    } else {
107        CoverageCheck {
108            control: "CGB-1.1",
109            title: "credential redaction",
110            status: CheckStatus::Fail,
111            detail: format!("unredacted credential classes: {}", missing.join(", ")),
112        }
113    });
114
115    // CGB-1.2 — declarative, reviewable rules (named patterns in TOML).
116    checks.push(if policy.redaction.is_empty() {
117        CoverageCheck {
118            control: "CGB-1.2",
119            title: "declarative redaction rules",
120            status: CheckStatus::Fail,
121            detail: "pack declares no named redaction patterns".to_string(),
122        }
123    } else {
124        CoverageCheck {
125            control: "CGB-1.2",
126            title: "declarative redaction rules",
127            status: CheckStatus::Pass,
128            detail: format!(
129                "{} named, versioned patterns (chain: {})",
130                policy.redaction.len(),
131                if policy.chain.is_empty() {
132                    "root pack".to_string()
133                } else {
134                    policy.chain.join(" → ")
135                }
136            ),
137        }
138    });
139
140    // CGB-1.3 — classification beyond secrets (regulated identifiers).
141    let domain_hits: Vec<&str> = DOMAIN_FIXTURES
142        .iter()
143        .filter(|(_, fixture)| matches(fixture))
144        .map(|(class, _)| *class)
145        .collect();
146    checks.push(if domain_hits.is_empty() {
147        CoverageCheck {
148            control: "CGB-1.3",
149            title: "beyond-secret classification",
150            status: CheckStatus::Inconclusive,
151            detail:
152                "no regulated-identifier patterns declared — acceptable outside regulated workloads"
153                    .to_string(),
154        }
155    } else {
156        CoverageCheck {
157            control: "CGB-1.3",
158            title: "beyond-secret classification",
159            status: CheckStatus::Pass,
160            detail: format!("regulated classes redacted: {}", domain_hits.join(", ")),
161        }
162    });
163
164    // CGB-3.2 — hard budget configured (pack aspect: context budget cap).
165    checks.push(match policy.max_context_tokens {
166        Some(cap) => CoverageCheck {
167            control: "CGB-3.2",
168            title: "context budget cap",
169            status: CheckStatus::Pass,
170            detail: format!("max_context_tokens = {cap}"),
171        },
172        None => CoverageCheck {
173            control: "CGB-3.2",
174            title: "context budget cap",
175            status: CheckStatus::Inconclusive,
176            detail: "no cap in pack — verify budget enforcement elsewhere".to_string(),
177        },
178    });
179
180    // CGB-4.3 — retention policy-driven (pack aspect: declared expectation).
181    checks.push(match policy.audit_retention_days {
182        Some(days) => CoverageCheck {
183            control: "CGB-4.3",
184            title: "audit retention declared",
185            status: CheckStatus::Pass,
186            detail: format!("audit_retention_days = {days}"),
187        },
188        None => CoverageCheck {
189            control: "CGB-4.3",
190            title: "audit retention declared",
191            status: CheckStatus::Inconclusive,
192            detail: "no retention expectation in pack".to_string(),
193        },
194    });
195
196    // CGB-5.4 — capabilities scoped (pack aspect: tool allow/deny posture).
197    let denies = policy.deny_tools.len();
198    checks.push(match (&policy.allow_tools, denies) {
199        (Some(allow), _) => CoverageCheck {
200            control: "CGB-5.4",
201            title: "tool surface scoped",
202            status: CheckStatus::Pass,
203            detail: format!(
204                "allowlist posture: {} tools permitted, rest denied",
205                allow.len()
206            ),
207        },
208        (None, d) if d > 0 => CoverageCheck {
209            control: "CGB-5.4",
210            title: "tool surface scoped",
211            status: CheckStatus::Pass,
212            detail: format!("denylist posture: {d} denied tool(s)"),
213        },
214        _ => CoverageCheck {
215            control: "CGB-5.4",
216            title: "tool surface scoped",
217            status: CheckStatus::Inconclusive,
218            detail: "pack neither allows nor denies tools — engine defaults apply".to_string(),
219        },
220    });
221
222    // CGB-5.5 — egress governed (pack aspect: egress tools restricted).
223    let egress_denied: Vec<&str> = policy
224        .deny_tools
225        .iter()
226        .filter(|t| {
227            let t = t.to_lowercase();
228            EGRESS_TOOL_HINTS.iter().any(|h| t.contains(h))
229        })
230        .map(String::as_str)
231        .collect();
232    let egress_allowed = policy.allow_tools.as_ref().map(|allow| {
233        allow
234            .iter()
235            .filter(|t| {
236                let t = t.to_lowercase();
237                EGRESS_TOOL_HINTS.iter().any(|h| t.contains(h))
238            })
239            .count()
240    });
241    checks.push(if !egress_denied.is_empty() {
242        CoverageCheck {
243            control: "CGB-5.5",
244            title: "egress restricted",
245            status: CheckStatus::Pass,
246            detail: format!("egress tools denied: {}", egress_denied.join(", ")),
247        }
248    } else if egress_allowed == Some(0) {
249        CoverageCheck {
250            control: "CGB-5.5",
251            title: "egress restricted",
252            status: CheckStatus::Pass,
253            detail: "allowlist contains no egress-capable tools".to_string(),
254        }
255    } else {
256        CoverageCheck {
257            control: "CGB-5.5",
258            title: "egress restricted",
259            status: CheckStatus::Inconclusive,
260            detail: "pack does not restrict egress tools — verify via roles/network policy"
261                .to_string(),
262        }
263    });
264
265    checks
266}
267
268/// Counts per status, for the summary line and JSON.
269#[derive(Debug, Serialize)]
270pub struct CoverageSummary {
271    pub pass: usize,
272    pub fail: usize,
273    pub inconclusive: usize,
274    /// Distinct controls the automated checks touch.
275    pub controls_covered: usize,
276    pub controls_total: usize,
277}
278
279/// Summarize a check run.
280pub fn summarize(checks: &[CoverageCheck]) -> CoverageSummary {
281    let mut covered: Vec<&str> = checks.iter().map(|c| c.control).collect();
282    covered.dedup();
283    CoverageSummary {
284        pass: checks
285            .iter()
286            .filter(|c| c.status == CheckStatus::Pass)
287            .count(),
288        fail: checks
289            .iter()
290            .filter(|c| c.status == CheckStatus::Fail)
291            .count(),
292        inconclusive: checks
293            .iter()
294            .filter(|c| c.status == CheckStatus::Inconclusive)
295            .count(),
296        controls_covered: covered.len(),
297        controls_total: CONTROLS_TOTAL,
298    }
299}
300
301#[cfg(test)]
302mod tests {
303    use super::super::builtin;
304    use super::*;
305
306    fn resolved(name: &str) -> ResolvedPolicy {
307        let pack = builtin::get(name).expect("built-in exists");
308        super::super::resolve(&pack).expect("resolves")
309    }
310
311    fn status_of(checks: &[CoverageCheck], control: &str) -> CheckStatus {
312        checks
313            .iter()
314            .find(|c| c.control == control)
315            .expect("control checked")
316            .status
317    }
318
319    #[test]
320    fn baseline_passes_credential_redaction() {
321        let checks = assess(&resolved("baseline"));
322        assert_eq!(status_of(&checks, "CGB-1.1"), CheckStatus::Pass);
323        assert_eq!(status_of(&checks, "CGB-1.2"), CheckStatus::Pass);
324        // baseline has no regulated-identifier classes and no tool posture.
325        assert_eq!(status_of(&checks, "CGB-1.3"), CheckStatus::Inconclusive);
326        assert_eq!(status_of(&checks, "CGB-5.4"), CheckStatus::Inconclusive);
327    }
328
329    #[test]
330    fn finance_eu_demonstrates_domain_classes_and_egress_denial() {
331        let checks = assess(&resolved("finance-eu"));
332        assert_eq!(status_of(&checks, "CGB-1.1"), CheckStatus::Pass);
333        assert_eq!(status_of(&checks, "CGB-1.3"), CheckStatus::Pass);
334        assert_eq!(status_of(&checks, "CGB-3.2"), CheckStatus::Pass);
335        assert_eq!(status_of(&checks, "CGB-4.3"), CheckStatus::Pass);
336        assert_eq!(status_of(&checks, "CGB-5.5"), CheckStatus::Pass);
337    }
338
339    #[test]
340    fn healthcare_demonstrates_phi_classes() {
341        let checks = assess(&resolved("healthcare"));
342        assert_eq!(status_of(&checks, "CGB-1.3"), CheckStatus::Pass);
343    }
344
345    #[test]
346    fn empty_policy_fails_credential_checks() {
347        let empty = ResolvedPolicy {
348            name: "empty".into(),
349            version: "0.0.1".into(),
350            description: String::new(),
351            chain: vec![],
352            default_read_mode: None,
353            allow_tools: None,
354            deny_tools: vec![],
355            max_context_tokens: None,
356            audit_retention_days: None,
357            redaction: std::collections::BTreeMap::new(),
358            filters: crate::core::policy::FilterRules::default(),
359            egress: crate::core::policy::EgressRules::default(),
360        };
361        let checks = assess(&empty);
362        assert_eq!(status_of(&checks, "CGB-1.1"), CheckStatus::Fail);
363        assert_eq!(status_of(&checks, "CGB-1.2"), CheckStatus::Fail);
364    }
365
366    #[test]
367    fn summary_counts_are_consistent() {
368        let checks = assess(&resolved("finance-eu"));
369        let s = summarize(&checks);
370        assert_eq!(s.pass + s.fail + s.inconclusive, checks.len());
371        assert_eq!(s.controls_total, CONTROLS_TOTAL);
372        assert!(s.controls_covered <= checks.len());
373    }
374}