Skip to main content

agent_graph_mcp/
evidence.rs

1use serde_json::Value;
2use sha2::{Digest, Sha256};
3
4use crate::store::PersistentStore;
5
6pub const MAX_WITNESS_CONTENT_BYTES: usize = 256 * 1024;
7pub const MAX_WITNESS_LOCATOR_BYTES: usize = 4096;
8#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
9pub struct EvidenceDisposition {
10    pub shape_valid: bool,
11    pub integrity_verified: bool,
12    pub source_witness_bound: bool,
13    pub source_authority: &'static str,
14    pub factual_support: &'static str,
15}
16
17impl EvidenceDisposition {
18    pub fn model_output() -> Self {
19        Self {
20            shape_valid: false,
21            integrity_verified: false,
22            source_witness_bound: false,
23            source_authority: "unverified",
24            factual_support: "unjudged",
25        }
26    }
27}
28
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct WitnessError {
31    pub code: String,
32    pub message: String,
33}
34
35impl WitnessError {
36    pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
37        Self {
38            code: code.into(),
39            message: message.into(),
40        }
41    }
42}
43
44impl std::fmt::Display for WitnessError {
45    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
46        write!(f, "{}: {}", self.code, self.message)
47    }
48}
49
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct WitnessCapture {
52    pub locator: String,
53    pub content: String,
54    pub media_type: String,
55    pub authority_class: String,
56    pub retrieved_at: String,
57}
58
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct WitnessRecord {
61    pub witness_id: String,
62    pub locator: String,
63    pub content: String,
64    pub media_type: String,
65    pub authority_class: String,
66    pub retrieved_at: String,
67    pub digest: String,
68}
69
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71pub struct WitnessSpan {
72    pub start: u64,
73    pub end: u64,
74}
75
76#[derive(Debug, Clone, PartialEq, Eq)]
77pub struct WitnessBinding {
78    pub witness_id: String,
79    pub span: WitnessSpan,
80}
81
82pub fn witness_envelope_digest(capture: &WitnessCapture) -> String {
83    // A struct gives the envelope a fixed field order independent of JSON map
84    // ordering. The captured content is hashed as supplied; it is never fetched
85    // or normalized by this crate.
86    #[derive(serde::Serialize)]
87    struct Envelope<'a> {
88        locator: &'a str,
89        content: &'a str,
90        media_type: &'a str,
91        authority_class: &'a str,
92        retrieved_at: &'a str,
93    }
94    let envelope = Envelope {
95        locator: &capture.locator,
96        content: &capture.content,
97        media_type: &capture.media_type,
98        authority_class: &capture.authority_class,
99        retrieved_at: &capture.retrieved_at,
100    };
101    let bytes = serde_json::to_vec(&envelope).expect("witness envelope serialization");
102    format!("sha256:{:x}", Sha256::digest(bytes))
103}
104
105/// Authenticate an integrity-sensitive envelope with a secret that remains
106/// outside SQLite, receipts, and bundles.
107pub fn hmac_sha256(value: &Value, key: &[u8]) -> String {
108    let bytes = serde_json::to_vec(value).unwrap_or_default();
109    let mut block = [0u8; 64];
110    if key.len() > block.len() {
111        block[..32].copy_from_slice(&Sha256::digest(key));
112    } else {
113        block[..key.len()].copy_from_slice(key);
114    }
115    let mut inner = [0u8; 64];
116    let mut outer = [0u8; 64];
117    for (index, byte) in block.iter().enumerate() {
118        inner[index] = byte ^ 0x36;
119        outer[index] = byte ^ 0x5c;
120    }
121    let mut inner_hash = Sha256::new();
122    inner_hash.update(inner);
123    inner_hash.update(bytes);
124    let mut outer_hash = Sha256::new();
125    outer_hash.update(outer);
126    outer_hash.update(inner_hash.finalize());
127    format!("hmac-sha256:{:x}", outer_hash.finalize())
128}
129
130pub fn witness_envelope_hmac(capture: &WitnessCapture, key: &[u8]) -> String {
131    #[derive(serde::Serialize)]
132    struct Envelope<'a> {
133        locator: &'a str,
134        content: &'a str,
135        media_type: &'a str,
136        authority_class: &'a str,
137        retrieved_at: &'a str,
138    }
139    let envelope = Envelope {
140        locator: &capture.locator,
141        content: &capture.content,
142        media_type: &capture.media_type,
143        authority_class: &capture.authority_class,
144        retrieved_at: &capture.retrieved_at,
145    };
146    hmac_sha256(
147        &serde_json::to_value(envelope).expect("witness envelope serialization"),
148        key,
149    )
150}
151
152pub fn witness_id_for_digest(digest: &str) -> String {
153    format!(
154        "witness-{}",
155        digest.strip_prefix("sha256:").unwrap_or(digest)
156    )
157}
158
159pub fn validate_witness_capture(capture: WitnessCapture) -> Result<WitnessRecord, WitnessError> {
160    validate_witness_capture_with_key(capture, None)
161}
162
163pub fn validate_witness_capture_with_key(
164    capture: WitnessCapture,
165    key: Option<&[u8]>,
166) -> Result<WitnessRecord, WitnessError> {
167    if capture.locator.trim().is_empty() {
168        return Err(WitnessError::new(
169            "WITNESS_INVALID_LOCATOR",
170            "locator must be non-empty",
171        ));
172    }
173    if capture.locator.len() > MAX_WITNESS_LOCATOR_BYTES {
174        return Err(WitnessError::new(
175            "WITNESS_LOCATOR_TOO_LARGE",
176            "locator exceeds 4096 UTF-8 bytes",
177        ));
178    }
179    if capture.locator.chars().any(char::is_control) {
180        return Err(WitnessError::new(
181            "WITNESS_INVALID_LOCATOR",
182            "locator must not contain control characters",
183        ));
184    }
185    if capture.content.is_empty() {
186        return Err(WitnessError::new(
187            "WITNESS_INVALID_CONTENT",
188            "content must be non-empty",
189        ));
190    }
191    if capture.content.len() > MAX_WITNESS_CONTENT_BYTES {
192        return Err(WitnessError::new(
193            "WITNESS_CONTENT_TOO_LARGE",
194            "content exceeds 256 KiB UTF-8 bytes",
195        ));
196    }
197    if !matches!(
198        capture.media_type.as_str(),
199        "text/plain" | "text/markdown" | "application/json"
200    ) {
201        return Err(WitnessError::new(
202            "WITNESS_INVALID_MEDIA_TYPE",
203            "media_type is not in the witness v1 allowlist",
204        ));
205    }
206    if !matches!(
207        capture.authority_class.as_str(),
208        "caller_supplied_unverified" | "local_primary_capture"
209    ) {
210        return Err(WitnessError::new(
211            "WITNESS_INVALID_AUTHORITY_CLASS",
212            "authority_class is not in the witness v1 allowlist",
213        ));
214    }
215    if chrono::DateTime::parse_from_rfc3339(&capture.retrieved_at).is_err() {
216        return Err(WitnessError::new(
217            "WITNESS_INVALID_TIMESTAMP",
218            "retrieved_at must be an RFC3339 timestamp",
219        ));
220    }
221    let digest = key.map_or_else(
222        || witness_envelope_digest(&capture),
223        |key| witness_envelope_hmac(&capture, key),
224    );
225    Ok(WitnessRecord {
226        witness_id: witness_id_for_digest(&digest),
227        locator: capture.locator,
228        content: capture.content,
229        media_type: capture.media_type,
230        authority_class: capture.authority_class,
231        retrieved_at: capture.retrieved_at,
232        digest,
233    })
234}
235
236pub fn verify_witness_record(record: &WitnessRecord) -> Result<(), WitnessError> {
237    verify_witness_record_with_key(record, None)
238}
239
240pub fn verify_witness_record_with_key(
241    record: &WitnessRecord,
242    key: Option<&[u8]>,
243) -> Result<(), WitnessError> {
244    let capture = WitnessCapture {
245        locator: record.locator.clone(),
246        content: record.content.clone(),
247        media_type: record.media_type.clone(),
248        authority_class: record.authority_class.clone(),
249        retrieved_at: record.retrieved_at.clone(),
250    };
251    let expected = validate_witness_capture_with_key(capture, key).map_err(|_| {
252        WitnessError::new(
253            "WITNESS_INTEGRITY_FAILURE",
254            "stored witness integrity validation failed",
255        )
256    })?;
257    if expected.digest != record.digest
258        || expected.witness_id != record.witness_id
259        || expected.locator != record.locator
260    {
261        return Err(WitnessError::new(
262            "WITNESS_INTEGRITY_FAILURE",
263            "stored witness integrity validation failed",
264        ));
265    }
266    Ok(())
267}
268
269pub fn digest(value: &Value) -> String {
270    let bytes = serde_json::to_vec(value).unwrap_or_default();
271    let hash = Sha256::digest(bytes);
272    format!("sha256:{hash:x}")
273}
274
275pub fn redact(value: &Value) -> Value {
276    match value {
277        Value::Object(map) => Value::Object(
278            map.iter()
279                .map(|(k, v)| {
280                    let lower = k.to_ascii_lowercase();
281                    let redacted = [
282                        "secret",
283                        "token",
284                        "password",
285                        "authorization",
286                        "api_key",
287                        "credential",
288                    ]
289                    .iter()
290                    .any(|needle| lower.contains(needle));
291                    (
292                        k.clone(),
293                        if redacted {
294                            Value::String("[REDACTED]".into())
295                        } else {
296                            redact(v)
297                        },
298                    )
299                })
300                .collect(),
301        ),
302        Value::Array(items) => Value::Array(items.iter().map(redact).collect()),
303        Value::String(text)
304            if text.starts_with("sk-")
305                || text.starts_with("Bearer ")
306                || text.contains("BEGIN PRIVATE KEY") =>
307        {
308            Value::String("[REDACTED]".into())
309        }
310        value => value.clone(),
311    }
312}
313
314pub fn bundle(
315    run_id: &str,
316    graph_version: &str,
317    input: &Value,
318    output: &Value,
319    receipt: &Value,
320) -> Value {
321    let dependency_envelopes_complete = receipt
322        .get("dependency_envelopes_complete")
323        .and_then(Value::as_bool)
324        .unwrap_or(false);
325    let payload = serde_json::json!({"schema":"agent-graph-mcp-bundle-v1","run_id":run_id,"graph_version":graph_version,
326        "input":redact(input),"output":redact(output),"receipt":redact(receipt),"replay_capability":"integrity_only",
327        "dependency_envelopes_complete":dependency_envelopes_complete,"environment":{}});
328    let integrity = digest(&payload);
329    serde_json::json!({"payload":payload,"integrity":integrity})
330}
331
332pub fn verify(bundle: &Value) -> Value {
333    let Some(payload) = bundle.get("payload") else {
334        return serde_json::json!({"verified":false,"code":"INVALID_BUNDLE"});
335    };
336    let expected = bundle
337        .get("integrity")
338        .and_then(Value::as_str)
339        .unwrap_or("");
340    let actual = digest(payload);
341    serde_json::json!({"verified":expected==actual,"level":"integrity_verified","expected":expected,"actual":actual,"models_or_tools_invoked":false})
342}
343
344/// Validate the small, deterministic evidence object contract used by research
345/// workflows. This checks shape and references only; it does not fetch or
346/// verify sources.
347pub fn validate_research_evidence(value: &Value) -> Result<(), String> {
348    let object = value
349        .as_object()
350        .ok_or_else(|| "research evidence must be an object".to_owned())?;
351    let claims = object
352        .get("claims")
353        .and_then(Value::as_array)
354        .ok_or_else(|| "research evidence requires a claims array".to_owned())?;
355    if claims.is_empty() {
356        return Err("research evidence claims array must not be empty".into());
357    }
358    let sources = object
359        .get("sources")
360        .and_then(Value::as_array)
361        .ok_or_else(|| "research evidence requires a sources array".to_owned())?;
362    if sources.is_empty() {
363        return Err("research evidence sources array must not be empty".into());
364    }
365    for (index, source) in sources.iter().enumerate() {
366        let locator = source.get("locator").and_then(Value::as_str).unwrap_or("");
367        if locator.trim().is_empty() {
368            return Err(format!(
369                "research evidence source {index} requires a non-empty locator"
370            ));
371        }
372        if source
373            .get("source_type")
374            .and_then(Value::as_str)
375            .is_none_or(|source_type| source_type.trim().is_empty())
376        {
377            return Err(format!(
378                "research evidence source {index} requires a non-empty source_type"
379            ));
380        }
381        if let Some(witness_id) = source.get("witness_id") {
382            if witness_id.as_str().is_none_or(str::is_empty) {
383                return Err(format!(
384                    "research evidence source {index} witness_id must be a non-empty string"
385                ));
386            }
387        }
388    }
389    for (index, claim) in claims.iter().enumerate() {
390        if claim
391            .get("text")
392            .and_then(Value::as_str)
393            .is_none_or(|text| text.trim().is_empty())
394        {
395            return Err(format!(
396                "research evidence claim {index} requires non-empty text"
397            ));
398        }
399        let locator_refs = claim
400            .get("source_locator")
401            .and_then(Value::as_str)
402            .into_iter()
403            .chain(claim.get("source").and_then(Value::as_str))
404            .chain(
405                claim
406                    .get("source_locators")
407                    .and_then(Value::as_array)
408                    .into_iter()
409                    .flatten()
410                    .filter_map(Value::as_str),
411            )
412            .any(|locator| {
413                sources
414                    .iter()
415                    .any(|source| source.get("locator").and_then(Value::as_str) == Some(locator))
416            });
417        let index_refs = claim
418            .get("source_index")
419            .and_then(Value::as_u64)
420            .into_iter()
421            .chain(
422                claim
423                    .get("source_indices")
424                    .and_then(Value::as_array)
425                    .into_iter()
426                    .flatten()
427                    .filter_map(Value::as_u64),
428            )
429            .any(|source_index| source_index < sources.len() as u64);
430        let witness_ids = claim_witness_ids(claim).map_err(|error| error.to_string())?;
431        if witness_ids.is_empty() {
432            return Err(format!(
433                "WITNESS_BINDING_REQUIRED: research evidence claim {index} requires one or more witness IDs"
434            ));
435        }
436        let _ = (locator_refs, index_refs);
437        parse_span(claim).map_err(|error| error.to_string())?;
438    }
439    Ok(())
440}
441
442fn claim_witness_ids(claim: &Value) -> Result<Vec<String>, WitnessError> {
443    let mut ids = Vec::new();
444    if let Some(id) = claim.get("witness_id") {
445        let id = id.as_str().ok_or_else(|| {
446            WitnessError::new(
447                "WITNESS_BINDING_REQUIRED",
448                "claim witness_id must be a non-empty string",
449            )
450        })?;
451        if id.is_empty() {
452            return Err(WitnessError::new(
453                "WITNESS_BINDING_REQUIRED",
454                "claim witness_id must be a non-empty string",
455            ));
456        }
457        ids.push(id.to_owned());
458    }
459    if let Some(raw_ids) = claim.get("witness_ids") {
460        let raw_ids = raw_ids.as_array().ok_or_else(|| {
461            WitnessError::new(
462                "WITNESS_BINDING_REQUIRED",
463                "claim witness_ids must be an array of strings",
464            )
465        })?;
466        if raw_ids.is_empty() {
467            return Err(WitnessError::new(
468                "WITNESS_BINDING_REQUIRED",
469                "claim witness_ids must not be empty",
470            ));
471        }
472        for raw_id in raw_ids {
473            let id = raw_id.as_str().ok_or_else(|| {
474                WitnessError::new(
475                    "WITNESS_BINDING_REQUIRED",
476                    "claim witness_ids must be an array of strings",
477                )
478            })?;
479            if id.is_empty() {
480                return Err(WitnessError::new(
481                    "WITNESS_BINDING_REQUIRED",
482                    "claim witness_ids must not contain empty strings",
483                ));
484            }
485            ids.push(id.to_owned());
486        }
487    }
488    ids.sort();
489    ids.dedup();
490    Ok(ids)
491}
492
493fn parse_span(claim: &Value) -> Result<WitnessSpan, WitnessError> {
494    let span = claim
495        .get("span")
496        .and_then(Value::as_object)
497        .ok_or_else(|| {
498            WitnessError::new(
499                "WITNESS_SPAN_REQUIRED",
500                "each witness-bound claim requires a span object",
501            )
502        })?;
503    let start = span.get("start").and_then(Value::as_u64).ok_or_else(|| {
504        WitnessError::new(
505            "WITNESS_SPAN_INVALID",
506            "witness span requires an unsigned start",
507        )
508    })?;
509    let end = span.get("end").and_then(Value::as_u64).ok_or_else(|| {
510        WitnessError::new(
511            "WITNESS_SPAN_INVALID",
512            "witness span requires an unsigned end",
513        )
514    })?;
515    if start >= end {
516        return Err(WitnessError::new(
517            "WITNESS_SPAN_INVALID",
518            "witness span must be non-empty with start < end",
519        ));
520    }
521    Ok(WitnessSpan { start, end })
522}
523
524pub fn witness_bindings(value: &Value) -> Result<Vec<WitnessBinding>, WitnessError> {
525    validate_research_evidence(value).map_err(|message| {
526        let (code, message) = message
527            .split_once(": ")
528            .map(|(code, message)| (code.to_owned(), message.to_owned()))
529            .unwrap_or_else(|| ("WITNESS_EVIDENCE_INVALID".to_owned(), message));
530        WitnessError::new(code, message)
531    })?;
532    let claims = value
533        .get("claims")
534        .and_then(Value::as_array)
535        .expect("validate_research_evidence checked claims");
536    let mut bindings = Vec::new();
537    for claim in claims {
538        let span = parse_span(claim)?;
539        for witness_id in claim_witness_ids(claim)? {
540            bindings.push(WitnessBinding { witness_id, span });
541        }
542    }
543    Ok(bindings)
544}
545
546pub fn validate_witness_dependencies(
547    value: &Value,
548    store: &PersistentStore,
549) -> Result<Value, WitnessError> {
550    let bindings = witness_bindings(value)?;
551    let mut dependencies = Vec::new();
552    let mut seen = std::collections::BTreeSet::new();
553    for binding in bindings {
554        let Some(record) = store.get_witness(&binding.witness_id)? else {
555            return Err(WitnessError::new(
556                "WITNESS_NOT_FOUND",
557                "referenced witness was not found in SQLite",
558            ));
559        };
560        let bytes = record.content.as_bytes();
561        let start = usize::try_from(binding.span.start).unwrap_or(usize::MAX);
562        let end = usize::try_from(binding.span.end).unwrap_or(usize::MAX);
563        if start >= end
564            || end > bytes.len()
565            || !record.content.is_char_boundary(start)
566            || !record.content.is_char_boundary(end)
567        {
568            return Err(WitnessError::new(
569                "WITNESS_SPAN_OUT_OF_RANGE",
570                "witness span is outside captured UTF-8 content",
571            ));
572        }
573        if seen.insert(binding.witness_id.clone()) {
574            dependencies.push(serde_json::json!({
575                "witness_id": record.witness_id,
576                "digest": record.digest,
577                "locator_digest": digest(&Value::String(record.locator)),
578            }));
579        }
580    }
581    Ok(Value::Array(dependencies))
582}
583
584#[cfg(test)]
585mod tests {
586    use super::{validate_research_evidence, validate_witness_dependencies, WitnessCapture};
587    use crate::store::PersistentStore;
588    use serde_json::json;
589
590    fn configure_test_integrity_key() {
591        let path = std::env::temp_dir().join("agent-graph-mcp-unit-integrity.key");
592        std::fs::write(&path, [0x5au8; 32]).expect("test integrity key");
593        std::env::set_var("AGENT_GRAPH_INTEGRITY_KEY_PATH", path);
594    }
595
596    #[test]
597    fn research_evidence_requires_claims_and_source_locators() {
598        assert!(validate_research_evidence(&json!({
599            "claims": [{"text": "claim", "witness_id": "witness-test", "span":{"start":0,"end":5}}],
600            "sources": [{"locator": "local://source", "source_type": "local", "witness_id":"witness-test"}]
601        }))
602        .is_ok());
603        assert!(validate_research_evidence(&json!({
604            "claims": [], "sources": [{"locator": "x"}]
605        }))
606        .is_err());
607        assert!(validate_research_evidence(&json!({
608            "claims": [{"text": "claim", "source_index": 0}],
609            "sources": [{"locator": "x"}]
610        }))
611        .is_err());
612        assert!(validate_research_evidence(&json!({
613            "claims": [{"text": "claim"}],
614            "sources": [{"locator": "x", "source_type": "web"}]
615        }))
616        .is_err());
617        assert!(validate_research_evidence(&json!({
618            "claims": [{"text": "claim", "source_index": 0}], "sources": [{"locator": "  ", "source_type": "web"}]
619        }))
620        .is_err());
621    }
622
623    #[test]
624    fn witness_binding_requires_ids_and_bounded_spans() {
625        assert!(validate_research_evidence(&json!({
626            "claims": [{"text":"claim","source_index":0}],
627            "sources": [{"locator":"local://source","source_type":"local"}]
628        }))
629        .is_err());
630        assert!(validate_research_evidence(&json!({
631            "claims": [{"text":"claim","witness_id":"witness-test","span":{"start":3,"end":3}}],
632            "sources": [{"locator":"local://source","source_type":"local","witness_id":"witness-test"}]
633        }))
634        .is_err());
635    }
636
637    #[test]
638    fn witness_dependencies_verify_sqlite_content_and_span() {
639        configure_test_integrity_key();
640        let temp = tempfile::tempdir().expect("witness database");
641        let store = PersistentStore::open(temp.path()).expect("store");
642        let record = store
643            .capture_witness(WitnessCapture {
644                locator: "local://source".into(),
645                content: "bounded text".into(),
646                media_type: "text/plain".into(),
647                authority_class: "caller_supplied_unverified".into(),
648                retrieved_at: "2026-07-21T12:00:00Z".into(),
649            })
650            .expect("capture");
651        let value = json!({
652            "claims": [{"text":"claim","witness_id":record.witness_id,"span":{"start":0,"end":7}}],
653            "sources": [{"locator":"local://source","source_type":"local"}]
654        });
655        let dependencies = validate_witness_dependencies(&value, &store).expect("valid span");
656        assert_eq!(dependencies[0]["digest"], record.digest);
657        assert_eq!(
658            dependencies[0]["locator_digest"].as_str().unwrap().len(),
659            71
660        );
661
662        let out_of_range = json!({
663            "claims": [{"text":"claim","witness_id":record.witness_id,"span":{"start":0,"end":99}}],
664            "sources": [{"locator":"local://source","source_type":"local"}]
665        });
666        assert_eq!(
667            validate_witness_dependencies(&out_of_range, &store)
668                .expect_err("range must fail")
669                .code,
670            "WITNESS_SPAN_OUT_OF_RANGE"
671        );
672    }
673
674    #[test]
675    fn evidence_requires_durable_witness_store() {
676        configure_test_integrity_key();
677        let temp = tempfile::tempdir().expect("witness database");
678        let store = PersistentStore::open(temp.path()).expect("store");
679        let value = json!({
680            "claims": [{"text":"claim","witness_id":"witness-absent","span":{"start":0,"end":5}}],
681            "sources": [{"locator":"local://missing","source_type":"local"}]
682        });
683        let error = validate_witness_dependencies(&value, &store)
684            .expect_err("missing durable witness must fail closed");
685        assert_eq!(error.code, "WITNESS_NOT_FOUND");
686    }
687}