Skip to main content

lean_ctx/core/
claim_extractor.rs

1//! Claim Extraction — decomposes context pipeline outputs into verifiable claims.
2//!
3//! Inspired by VERGE (arXiv:2601.20055): instead of verifying entire outputs
4//! monolithically, we decompose them into atomic, individually verifiable claims
5//! and route each to the appropriate verifier.
6
7use std::path::Path;
8
9use super::context_proof_v2::{
10    Claim, ClaimKind, ClaimStatus, ContextProofV2, VerifierKind, deterministic_claim, policy_claim,
11};
12
13pub struct ClaimExtractor {
14    proof: ContextProofV2,
15}
16
17impl ClaimExtractor {
18    pub fn new(run_id: &str, session_id: Option<&str>) -> Self {
19        Self {
20            proof: ContextProofV2::new(
21                run_id.to_string(),
22                session_id.map(std::string::ToString::to_string),
23            ),
24        }
25    }
26
27    pub fn verify_pathjail(&mut self, path: &str, jail_root: &Path) {
28        let resolved = crate::core::pathjail::jail_path(&std::path::PathBuf::from(path), jail_root);
29        let passed = resolved.is_ok();
30        self.proof.add_claim(policy_claim(
31            &format!("pathjail:{path}"),
32            &format!("Path '{path}' is within jail root"),
33            ClaimKind::PathjailCompliance,
34            passed,
35        ));
36    }
37
38    pub fn verify_no_secrets_in_output(&mut self, output: &str) {
39        let secret_matches = crate::core::secret_detection::detect_secrets(output);
40
41        let passed = secret_matches.is_empty();
42        let text = if passed {
43            "No secret patterns found in output".to_string()
44        } else {
45            let names: Vec<&str> = secret_matches.iter().map(|m| m.pattern_name).collect();
46            let mut unique_names: Vec<&str> = names.clone();
47            unique_names.sort_unstable();
48            unique_names.dedup();
49            format!(
50                "Secret patterns detected ({}): {}",
51                secret_matches.len(),
52                unique_names.join(", ")
53            )
54        };
55
56        self.proof.add_claim(policy_claim(
57            "no_secrets",
58            &text,
59            ClaimKind::SecretPolicy,
60            passed,
61        ));
62    }
63
64    pub fn verify_signatures_preserved(&mut self, original: &[String], compressed: &[String]) {
65        let mut missing = Vec::new();
66        for sig in original {
67            if !compressed.iter().any(|c| c.contains(sig)) {
68                missing.push(sig.clone());
69            }
70        }
71
72        let passed = missing.is_empty();
73        let text = if passed {
74            format!("All {} signatures preserved", original.len())
75        } else {
76            format!(
77                "{} of {} signatures missing: {:?}",
78                missing.len(),
79                original.len(),
80                &missing[..missing.len().min(3)]
81            )
82        };
83
84        self.proof.add_claim(Claim {
85            id: "signatures_preserved".to_string(),
86            text,
87            kind: ClaimKind::CompressionInvariant,
88            verifier: VerifierKind::Ast,
89            status: if passed {
90                ClaimStatus::Passed
91            } else {
92                ClaimStatus::Failed
93            },
94            evidence_ref: None,
95        });
96    }
97
98    pub fn verify_imports_preserved(&mut self, original_imports: &[String], compressed: &str) {
99        let mut missing = Vec::new();
100        for imp in original_imports {
101            if !compressed.contains(imp) {
102                missing.push(imp.clone());
103            }
104        }
105
106        let passed = missing.is_empty();
107        let text = if passed {
108            format!("All {} imports preserved", original_imports.len())
109        } else {
110            format!(
111                "{} of {} imports missing",
112                missing.len(),
113                original_imports.len()
114            )
115        };
116
117        self.proof
118            .add_claim(deterministic_claim("imports_preserved", &text, passed));
119    }
120
121    pub fn add_custom_claim(&mut self, claim: Claim) {
122        self.proof.add_claim(claim);
123    }
124
125    pub fn finalize(self) -> ContextProofV2 {
126        self.proof
127    }
128}
129
130#[cfg(test)]
131pub mod tests {
132    use super::*;
133
134    #[test]
135    fn secret_detection_catches_aws_keys() {
136        let mut ext = ClaimExtractor::new("test_1", None);
137        ext.verify_no_secrets_in_output(concat!("const key = 'AK", "IAIOSFODNN7EXAMPLE'"));
138        let proof = ext.finalize();
139        assert_eq!(proof.summary.failed, 1);
140    }
141
142    #[test]
143    fn clean_output_passes_secret_check() {
144        let mut ext = ClaimExtractor::new("test_2", None);
145        ext.verify_no_secrets_in_output("fn main() { println!(\"hello\"); }");
146        let proof = ext.finalize();
147        assert_eq!(proof.summary.passed, 1);
148        assert_eq!(proof.summary.failed, 0);
149    }
150
151    #[test]
152    fn signatures_preserved_check() {
153        let mut ext = ClaimExtractor::new("test_3", None);
154        let original = vec!["fn main".to_string(), "pub fn process".to_string()];
155        let compressed = vec![
156            "fn main() { ... }".to_string(),
157            "pub fn process(data: &[u8]) -> Result<(), Error>".to_string(),
158        ];
159        ext.verify_signatures_preserved(&original, &compressed);
160        let proof = ext.finalize();
161        assert_eq!(proof.summary.passed, 1);
162    }
163
164    #[test]
165    fn missing_signatures_detected() {
166        let mut ext = ClaimExtractor::new("test_4", None);
167        let original = vec!["fn main()".to_string(), "pub fn gone()".to_string()];
168        let compressed = vec!["fn main() { ... }".to_string()];
169        ext.verify_signatures_preserved(&original, &compressed);
170        let proof = ext.finalize();
171        assert_eq!(proof.summary.failed, 1);
172    }
173
174    #[test]
175    fn pathjail_claim_reports_what_the_build_enforces() {
176        // Hold the env lock with a clean config: parallel pathjail tests set
177        // LEAN_CTX_ALLOW_PATH=/ under that lock, which would admit any path.
178        let _iso = crate::core::data_dir::isolated_data_dir();
179        let root = tempfile::tempdir().unwrap();
180        let outside = tempfile::tempdir().unwrap();
181        let escaped = outside.path().join("secret.txt");
182        std::fs::write(&escaped, "outside the jail").unwrap();
183        let mut ext = ClaimExtractor::new("test_5", None);
184        ext.verify_pathjail(&escaped.to_string_lossy(), root.path());
185        let proof = ext.finalize();
186        // A normal build rejects the escape and must say so. The `no-jail`
187        // build (part of `--all-features`) admits every path, so the claim
188        // must honestly report that the path was allowed.
189        let (failed, passed, level) = if cfg!(feature = "no-jail") {
190            (
191                0,
192                1,
193                super::super::context_proof_v2::QualityLevel::PolicyChecked,
194            )
195        } else {
196            (
197                1,
198                0,
199                super::super::context_proof_v2::QualityLevel::Provenance,
200            )
201        };
202        assert_eq!(proof.summary.failed, failed);
203        assert_eq!(proof.summary.passed, passed);
204        assert_eq!(proof.quality_level, level);
205    }
206
207    #[test]
208    fn combined_extraction_computes_quality() {
209        let _iso = crate::core::data_dir::isolated_data_dir();
210        let root = tempfile::tempdir().unwrap();
211        let inside = root.path().join("src.rs");
212        std::fs::write(&inside, "fn main() {}").unwrap();
213        let mut ext = ClaimExtractor::new("test_6", None);
214        ext.verify_no_secrets_in_output("clean code");
215        ext.verify_pathjail(&inside.to_string_lossy(), root.path());
216        let proof = ext.finalize();
217        assert_eq!(proof.summary.passed, 2);
218        assert_eq!(
219            proof.quality_level,
220            super::super::context_proof_v2::QualityLevel::PolicyChecked
221        );
222    }
223
224    #[test]
225    fn detects_github_pat() {
226        let mut ext = ClaimExtractor::new("test_gh", None);
227        ext.verify_no_secrets_in_output(concat!("token = gh", "p_1234567890abcdef"));
228        let proof = ext.finalize();
229        assert_eq!(proof.summary.failed, 1);
230    }
231
232    #[test]
233    fn detects_gitlab_pat() {
234        let mut ext = ClaimExtractor::new("test_gl", None);
235        ext.verify_no_secrets_in_output("glpat-xxxxxxxxxxxxxxxxxxxx");
236        let proof = ext.finalize();
237        assert_eq!(proof.summary.failed, 1);
238    }
239
240    #[test]
241    fn detects_pem_private_key() {
242        let mut ext = ClaimExtractor::new("test_pem", None);
243        ext.verify_no_secrets_in_output("-----BEGIN RSA PRIVATE KEY-----");
244        let proof = ext.finalize();
245        assert_eq!(proof.summary.failed, 1);
246    }
247
248    #[test]
249    fn empty_signatures_passes() {
250        let mut ext = ClaimExtractor::new("test_empty", None);
251        ext.verify_signatures_preserved(&[], &[]);
252        let proof = ext.finalize();
253        assert_eq!(proof.summary.passed, 1);
254        assert_eq!(proof.summary.failed, 0);
255    }
256
257    #[test]
258    fn empty_imports_passes() {
259        let mut ext = ClaimExtractor::new("test_imp", None);
260        ext.verify_imports_preserved(&[], "some content");
261        let proof = ext.finalize();
262        assert_eq!(proof.summary.passed, 1);
263    }
264
265    #[test]
266    fn finalize_returns_correct_run_id() {
267        let ext = ClaimExtractor::new("my_run_42", Some("sess_7"));
268        let proof = ext.finalize();
269        assert_eq!(proof.run_id, "my_run_42");
270        assert_eq!(proof.session_id, Some("sess_7".to_string()));
271    }
272
273    #[test]
274    fn multiple_secret_patterns_all_detected() {
275        let mut ext = ClaimExtractor::new("test_multi", None);
276        ext.verify_no_secrets_in_output(concat!(
277            "AK",
278            "IAIOSFODNN7EXAMPLE1 and ",
279            "sk",
280            "-abcdefghijklmnopqrstuvwx and password=longvalue1234567890extra",
281        ));
282        let proof = ext.finalize();
283        assert_eq!(proof.summary.failed, 1);
284        assert!(proof.claims[0].text.contains("aws_key"));
285    }
286}