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.
49pub(crate) const CREDENTIAL_FIXTURES: &[(&str, &str)] = &[
50    ("private key block", "-----BEGIN RSA PRIVATE KEY-----"),
51    ("cloud access key", concat!("AK", "IAIOSFODNN7EXAMPLE")),
52    (
53        "credential assignment",
54        concat!("api_key = \"sk", "-supersecretvalue1234\""),
55    ),
56    (
57        "bearer token",
58        "Authorization: Bearer abcdefghij0123456789xyz",
59    ),
60];
61
62/// Synthetic non-credential sensitivity fixtures for CGB-1.3 (regulated
63/// identifiers). Matching ≥ 1 demonstrates classification beyond secrets.
64pub(crate) const DOMAIN_FIXTURES: &[(&str, &str)] = &[
65    ("IBAN", "DE89 3704 0044 0532 0130 00"),
66    ("payment card", "4111 1111 1111 1111"),
67    ("US SSN", "SSN: 123-45-6789"),
68    ("date of birth", "DOB: 03/14/1975"),
69];
70
71/// Tools that reach the network beyond the model provider (CGB-5.5 aspect:
72/// egress deniable by policy). An explicit list, not a name heuristic
73/// (GHSA-jhf9-3pv4-m469): `ctx_provider` (GitHub/GitLab/Jira/Postgres) and
74/// `ctx_tools` (MCP gateway) do not look like fetch tools but egress all the
75/// same. `ctx_preload`'s provider prefetch is gated on `ctx_provider`.
76pub(crate) const EGRESS_CAPABLE_TOOLS: &[&str] = &["ctx_url_read", "ctx_provider", "ctx_tools"];
77
78/// Egress-capable tools a resolved pack still permits (same allow/deny
79/// semantics as the runtime gate: deny wins, an allowlist is exclusive).
80pub(crate) fn egress_tools_permitted(policy: &ResolvedPolicy) -> Vec<&'static str> {
81    EGRESS_CAPABLE_TOOLS
82        .iter()
83        .copied()
84        .filter(|tool| {
85            !policy.deny_tools.iter().any(|t| t == tool)
86                && policy
87                    .allow_tools
88                    .as_ref()
89                    .is_none_or(|allow| allow.iter().any(|t| t == tool))
90        })
91        .collect()
92}
93
94/// Run all automated checks against a resolved policy.
95pub fn assess(policy: &ResolvedPolicy) -> Vec<CoverageCheck> {
96    let patterns: Vec<(String, Regex)> = policy
97        .redaction
98        .iter()
99        .filter_map(|(name, raw)| Regex::new(raw).ok().map(|re| (name.clone(), re)))
100        .collect();
101
102    let matches = |fixture: &str| patterns.iter().any(|(_, re)| re.is_match(fixture));
103
104    let mut checks = Vec::new();
105
106    // CGB-1.1 — credential material never reaches the model.
107    let missing: Vec<&str> = CREDENTIAL_FIXTURES
108        .iter()
109        .filter(|(_, fixture)| !matches(fixture))
110        .map(|(class, _)| *class)
111        .collect();
112    checks.push(if missing.is_empty() {
113        CoverageCheck {
114            control: "CGB-1.1",
115            title: "credential redaction",
116            status: CheckStatus::Pass,
117            detail: format!(
118                "{}/{} credential fixture classes matched by redaction patterns",
119                CREDENTIAL_FIXTURES.len(),
120                CREDENTIAL_FIXTURES.len()
121            ),
122        }
123    } else {
124        CoverageCheck {
125            control: "CGB-1.1",
126            title: "credential redaction",
127            status: CheckStatus::Fail,
128            detail: format!("unredacted credential classes: {}", missing.join(", ")),
129        }
130    });
131
132    // CGB-1.2 — declarative, reviewable rules (named patterns in TOML).
133    checks.push(if policy.redaction.is_empty() {
134        CoverageCheck {
135            control: "CGB-1.2",
136            title: "declarative redaction rules",
137            status: CheckStatus::Fail,
138            detail: "pack declares no named redaction patterns".to_string(),
139        }
140    } else {
141        CoverageCheck {
142            control: "CGB-1.2",
143            title: "declarative redaction rules",
144            status: CheckStatus::Pass,
145            detail: format!(
146                "{} named, versioned patterns (chain: {})",
147                policy.redaction.len(),
148                if policy.chain.is_empty() {
149                    "root pack".to_string()
150                } else {
151                    policy.chain.join(" → ")
152                }
153            ),
154        }
155    });
156
157    // CGB-1.3 — classification beyond secrets (regulated identifiers).
158    let domain_hits: Vec<&str> = DOMAIN_FIXTURES
159        .iter()
160        .filter(|(_, fixture)| matches(fixture))
161        .map(|(class, _)| *class)
162        .collect();
163    checks.push(if domain_hits.is_empty() {
164        CoverageCheck {
165            control: "CGB-1.3",
166            title: "beyond-secret classification",
167            status: CheckStatus::Inconclusive,
168            detail:
169                "no regulated-identifier patterns declared — acceptable outside regulated workloads"
170                    .to_string(),
171        }
172    } else {
173        CoverageCheck {
174            control: "CGB-1.3",
175            title: "beyond-secret classification",
176            status: CheckStatus::Pass,
177            detail: format!("regulated classes redacted: {}", domain_hits.join(", ")),
178        }
179    });
180
181    // CGB-3.2 — hard budget configured (pack aspect: context budget cap).
182    checks.push(match policy.max_context_tokens {
183        Some(cap) => CoverageCheck {
184            control: "CGB-3.2",
185            title: "context budget cap",
186            status: CheckStatus::Pass,
187            detail: format!("max_context_tokens = {cap}"),
188        },
189        None => CoverageCheck {
190            control: "CGB-3.2",
191            title: "context budget cap",
192            status: CheckStatus::Inconclusive,
193            detail: "no cap in pack — verify budget enforcement elsewhere".to_string(),
194        },
195    });
196
197    // CGB-4.3 — retention policy-driven (pack aspect: declared expectation).
198    checks.push(match policy.audit_retention_days {
199        Some(days) => CoverageCheck {
200            control: "CGB-4.3",
201            title: "audit retention declared",
202            status: CheckStatus::Pass,
203            detail: format!("audit_retention_days = {days}"),
204        },
205        None => CoverageCheck {
206            control: "CGB-4.3",
207            title: "audit retention declared",
208            status: CheckStatus::Inconclusive,
209            detail: "no retention expectation in pack".to_string(),
210        },
211    });
212
213    // CGB-5.4 — capabilities scoped (pack aspect: tool allow/deny posture).
214    let denies = policy.deny_tools.len();
215    checks.push(match (&policy.allow_tools, denies) {
216        (Some(allow), _) => CoverageCheck {
217            control: "CGB-5.4",
218            title: "tool surface scoped",
219            status: CheckStatus::Pass,
220            detail: format!(
221                "allowlist posture: {} tools permitted, rest denied",
222                allow.len()
223            ),
224        },
225        (None, d) if d > 0 => CoverageCheck {
226            control: "CGB-5.4",
227            title: "tool surface scoped",
228            status: CheckStatus::Pass,
229            detail: format!("denylist posture: {d} denied tool(s)"),
230        },
231        _ => CoverageCheck {
232            control: "CGB-5.4",
233            title: "tool surface scoped",
234            status: CheckStatus::Inconclusive,
235            detail: "pack neither allows nor denies tools — instance defaults apply".to_string(),
236        },
237    });
238
239    // CGB-5.5 — egress governed (pack aspect: egress tools restricted).
240    // PASS only when every egress-capable tool is blocked; a pack that
241    // restricts some of them but not all FAILs and names the open ones.
242    let still_open = egress_tools_permitted(policy);
243    checks.push(if still_open.is_empty() {
244        CoverageCheck {
245            control: "CGB-5.5",
246            title: "egress restricted",
247            status: CheckStatus::Pass,
248            detail: format!(
249                "all egress-capable tools blocked: {}",
250                EGRESS_CAPABLE_TOOLS.join(", ")
251            ),
252        }
253    } else if still_open.len() < EGRESS_CAPABLE_TOOLS.len() {
254        CoverageCheck {
255            control: "CGB-5.5",
256            title: "egress restricted",
257            status: CheckStatus::Fail,
258            detail: format!(
259                "egress only partially restricted — still permitted: {}",
260                still_open.join(", ")
261            ),
262        }
263    } else {
264        CoverageCheck {
265            control: "CGB-5.5",
266            title: "egress restricted",
267            status: CheckStatus::Inconclusive,
268            detail: "pack does not restrict egress tools — verify via roles/network policy"
269                .to_string(),
270        }
271    });
272
273    checks
274}
275
276/// Counts per status, for the summary line and JSON.
277#[derive(Debug, Serialize)]
278pub struct CoverageSummary {
279    pub pass: usize,
280    pub fail: usize,
281    pub inconclusive: usize,
282    /// Distinct controls the automated checks touch.
283    pub controls_covered: usize,
284    pub controls_total: usize,
285}
286
287/// Summarize a check run.
288pub fn summarize(checks: &[CoverageCheck]) -> CoverageSummary {
289    let mut covered: Vec<&str> = checks.iter().map(|c| c.control).collect();
290    covered.dedup();
291    CoverageSummary {
292        pass: checks
293            .iter()
294            .filter(|c| c.status == CheckStatus::Pass)
295            .count(),
296        fail: checks
297            .iter()
298            .filter(|c| c.status == CheckStatus::Fail)
299            .count(),
300        inconclusive: checks
301            .iter()
302            .filter(|c| c.status == CheckStatus::Inconclusive)
303            .count(),
304        controls_covered: covered.len(),
305        controls_total: CONTROLS_TOTAL,
306    }
307}
308
309#[cfg(test)]
310mod tests {
311    use super::super::builtin;
312    use super::*;
313
314    fn resolved(name: &str) -> ResolvedPolicy {
315        let pack = builtin::get(name).expect("built-in exists");
316        super::super::resolve(&pack).expect("resolves")
317    }
318
319    /// A user-authored regulated pack: own identifier patterns on top of the
320    /// community floor, web egress denied, budget and retention pinned.
321    fn regulated() -> ResolvedPolicy {
322        let pack = super::super::parse(
323            r#"
324name = "regulated-test"
325version = "1.0.0"
326description = "test pack with own identifier classes"
327extends = "strict-redaction"
328
329[context]
330deny_tools = ["ctx_url_read", "ctx_provider", "ctx_tools"]
331max_context_tokens = 12000
332audit_retention_days = 365
333
334[redaction]
335iban = '\b[A-Z]{2}\d{2}[A-Z0-9]{11,30}\b'
336us_ssn = '\b\d{3}-\d{2}-\d{4}\b'
337"#,
338        )
339        .expect("parses");
340        super::super::resolve(&pack).expect("resolves")
341    }
342
343    fn status_of(checks: &[CoverageCheck], control: &str) -> CheckStatus {
344        checks
345            .iter()
346            .find(|c| c.control == control)
347            .expect("control checked")
348            .status
349    }
350
351    #[test]
352    fn baseline_passes_credential_redaction() {
353        let checks = assess(&resolved("baseline"));
354        assert_eq!(status_of(&checks, "CGB-1.1"), CheckStatus::Pass);
355        assert_eq!(status_of(&checks, "CGB-1.2"), CheckStatus::Pass);
356        // baseline has no regulated-identifier classes and no tool posture.
357        assert_eq!(status_of(&checks, "CGB-1.3"), CheckStatus::Inconclusive);
358        assert_eq!(status_of(&checks, "CGB-5.4"), CheckStatus::Inconclusive);
359    }
360
361    #[test]
362    fn regulated_pack_demonstrates_domain_classes_and_egress_denial() {
363        let checks = assess(&regulated());
364        assert_eq!(status_of(&checks, "CGB-1.1"), CheckStatus::Pass);
365        assert_eq!(status_of(&checks, "CGB-1.3"), CheckStatus::Pass);
366        assert_eq!(status_of(&checks, "CGB-3.2"), CheckStatus::Pass);
367        assert_eq!(status_of(&checks, "CGB-4.3"), CheckStatus::Pass);
368        assert_eq!(status_of(&checks, "CGB-5.5"), CheckStatus::Pass);
369    }
370
371    /// GHSA-jhf9-3pv4-m469: denying only `ctx_url_read` is not "egress
372    /// restricted" while providers and the MCP gateway still egress.
373    #[test]
374    fn partial_egress_denial_fails_and_names_open_tools() {
375        let mut policy = regulated();
376        assert_eq!(status_of(&assess(&policy), "CGB-5.5"), CheckStatus::Pass);
377        policy.deny_tools = vec!["ctx_url_read".into()];
378        let checks = assess(&policy);
379        let check = checks.iter().find(|c| c.control == "CGB-5.5").unwrap();
380        assert_eq!(check.status, CheckStatus::Fail);
381        assert!(check.detail.contains("ctx_provider"), "{}", check.detail);
382        assert!(check.detail.contains("ctx_tools"), "{}", check.detail);
383        policy.deny_tools.clear();
384        policy.allow_tools = Some(vec!["ctx_read".into()]);
385        assert_eq!(status_of(&assess(&policy), "CGB-5.5"), CheckStatus::Pass);
386    }
387
388    #[test]
389    fn empty_policy_fails_credential_checks() {
390        let empty = ResolvedPolicy {
391            name: "empty".into(),
392            version: "0.0.1".into(),
393            description: String::new(),
394            chain: vec![],
395            default_read_mode: None,
396            allow_tools: None,
397            deny_tools: vec![],
398            max_context_tokens: None,
399            audit_retention_days: None,
400            redaction: std::collections::BTreeMap::new(),
401            filters: crate::core::policy::FilterRules::default(),
402            egress: crate::core::policy::EgressRules::default(),
403            routing: crate::core::policy::RoutingPolicyRules::default(),
404            budgets: crate::core::policy::BudgetRules::default(),
405        };
406        let checks = assess(&empty);
407        assert_eq!(status_of(&checks, "CGB-1.1"), CheckStatus::Fail);
408        assert_eq!(status_of(&checks, "CGB-1.2"), CheckStatus::Fail);
409    }
410
411    #[test]
412    fn summary_counts_are_consistent() {
413        let checks = assess(&regulated());
414        let s = summarize(&checks);
415        assert_eq!(s.pass + s.fail + s.inconclusive, checks.len());
416        assert_eq!(s.controls_total, CONTROLS_TOTAL);
417        assert!(s.controls_covered <= checks.len());
418    }
419}