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 #[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
105pub 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
344pub 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}