1use crate::agent::AgentCertificate;
17use crate::session::package::{VerifyCheck, VerifyStatus};
18use crate::session::receipt::SessionReceipt;
19
20pub fn verify_receipt_json_checks(receipt: &SessionReceipt) -> Vec<VerifyCheck> {
30 use crate::merkle::MerkleTree;
31
32 let mut checks: Vec<VerifyCheck> = Vec::new();
33
34 if !receipt.artifacts.is_empty() {
35 let version = receipt.merkle.merkle_version;
40 let mut tree = match MerkleTree::with_version(version) {
41 Ok(t) => t,
42 Err(e) => {
43 checks.push(VerifyCheck::fail(
44 "merkle_root",
45 &format!("receipt declared unknown merkle_version: {e}"),
46 ));
47 return finish_with_leaf_count_and_timeline(receipt, checks);
50 }
51 };
52 for a in &receipt.artifacts {
53 tree.append(&a.artifact_id);
54 }
55 let root_bytes = tree.root();
56 let recomputed_root = root_bytes.map(|r| format!("mroot_{}", hex::encode(r)));
57 let root_hex = root_bytes.map(hex::encode).unwrap_or_default();
58
59 if recomputed_root == receipt.merkle.root {
60 checks.push(VerifyCheck::pass(
61 "merkle_root",
62 "Merkle root matches recomputed value",
63 ));
64 } else {
65 checks.push(VerifyCheck::fail(
66 "merkle_root",
67 &format!(
68 "recomputed {recomputed_root:?} != receipt {:?}",
69 receipt.merkle.root
70 ),
71 ));
72 }
73
74 let proof_total = receipt.merkle.inclusion_proofs.len();
75 let mut proofs_passed = 0usize;
76 let mut drift_detected = false;
77 for entry in &receipt.merkle.inclusion_proofs {
78 if entry.proof.merkle_version != version {
83 drift_detected = true;
84 continue;
85 }
86 if MerkleTree::verify_proof(version, &root_hex, &entry.artifact_id, &entry.proof) {
87 proofs_passed += 1;
88 }
89 }
90 if drift_detected {
91 checks.push(VerifyCheck::fail(
92 "inclusion_proofs",
93 &format!("per-proof merkle_version drift detected (section declares v{version})",),
94 ));
95 } else if proof_total == 0 {
96 checks.push(VerifyCheck::warn(
102 "inclusion_proofs",
103 "no inclusion proofs present to verify",
104 ));
105 } else if proofs_passed == proof_total {
106 checks.push(VerifyCheck::pass(
107 "inclusion_proofs",
108 &format!("{proofs_passed}/{proof_total} inclusion proofs passed"),
109 ));
110 } else {
111 checks.push(VerifyCheck::fail(
112 "inclusion_proofs",
113 &format!("{proofs_passed}/{proof_total} inclusion proofs passed"),
114 ));
115 }
116 } else {
117 checks.push(VerifyCheck::warn("merkle_root", "No artifacts to verify"));
118 }
119
120 finish_with_leaf_count_and_timeline(receipt, checks)
121}
122
123fn finish_with_leaf_count_and_timeline(
127 receipt: &SessionReceipt,
128 mut checks: Vec<VerifyCheck>,
129) -> Vec<VerifyCheck> {
130 if receipt.merkle.leaf_count == receipt.artifacts.len() {
131 checks.push(VerifyCheck::pass(
132 "leaf_count",
133 "Leaf count matches artifact count",
134 ));
135 } else {
136 checks.push(VerifyCheck::fail(
137 "leaf_count",
138 &format!(
139 "leaf_count {} != artifact count {}",
140 receipt.merkle.leaf_count,
141 receipt.artifacts.len()
142 ),
143 ));
144 }
145
146 let ordered = receipt.timeline.windows(2).all(|w| {
147 (&w[0].timestamp, w[0].sequence_no, &w[0].event_id)
148 <= (&w[1].timestamp, w[1].sequence_no, &w[1].event_id)
149 });
150 if ordered {
151 checks.push(VerifyCheck::pass(
152 "timeline_order",
153 "Timeline is correctly ordered",
154 ));
155 } else {
156 checks.push(VerifyCheck::fail(
157 "timeline_order",
158 "Timeline entries are not in deterministic order",
159 ));
160 }
161
162 checks
176}
177
178pub fn checks_ok(checks: &[VerifyCheck]) -> bool {
180 checks.iter().all(|c| c.status != VerifyStatus::Fail)
181}
182
183#[derive(Debug, Clone)]
185pub struct CrossVerifyResult {
186 pub ship_id_status: ShipIdStatus,
188 pub certificate_status: CertificateStatus,
190 pub authorized_tool_calls: Vec<String>,
192 pub unauthorized_tool_calls: Vec<String>,
195 pub authorized_tools_never_called: Vec<String>,
199}
200
201impl CrossVerifyResult {
202 pub fn ok(&self) -> bool {
205 matches!(self.ship_id_status, ShipIdStatus::Match)
206 && matches!(self.certificate_status, CertificateStatus::Valid)
207 && self.unauthorized_tool_calls.is_empty()
208 }
209}
210
211#[derive(Debug, Clone, PartialEq, Eq)]
213pub enum ShipIdStatus {
214 Match,
216 Mismatch {
218 receipt: String,
219 certificate: String,
220 },
221 Unknown,
225}
226
227#[derive(Debug, Clone, PartialEq, Eq)]
229pub enum CertificateStatus {
230 Valid,
231 Expired {
233 valid_until: String,
234 now: String,
235 },
236 NotYetValid {
238 issued_at: String,
239 now: String,
240 },
241}
242
243pub fn cross_verify_receipt_and_certificate(
250 receipt: &SessionReceipt,
251 certificate: &AgentCertificate,
252 now_rfc3339: &str,
253) -> CrossVerifyResult {
254 let ship_id_status = compare_ship_ids(
255 receipt.session.ship_id.as_deref(),
256 &certificate.identity.ship_id,
257 );
258 let certificate_status = classify_certificate_validity(certificate, now_rfc3339);
259 let (authorized_tool_calls, unauthorized_tool_calls, authorized_tools_never_called) =
260 classify_tool_usage(receipt, certificate);
261
262 CrossVerifyResult {
263 ship_id_status,
264 certificate_status,
265 authorized_tool_calls,
266 unauthorized_tool_calls,
267 authorized_tools_never_called,
268 }
269}
270
271fn compare_ship_ids(receipt: Option<&str>, certificate: &str) -> ShipIdStatus {
272 match receipt {
273 Some(r) if r == certificate => ShipIdStatus::Match,
274 Some(r) => ShipIdStatus::Mismatch {
275 receipt: r.to_string(),
276 certificate: certificate.to_string(),
277 },
278 None => ShipIdStatus::Unknown,
279 }
280}
281
282fn classify_certificate_validity(certificate: &AgentCertificate, now: &str) -> CertificateStatus {
283 let identity = &certificate.identity;
287 if now < identity.issued_at.as_str() {
288 return CertificateStatus::NotYetValid {
289 issued_at: identity.issued_at.clone(),
290 now: now.to_string(),
291 };
292 }
293 if now > identity.valid_until.as_str() {
294 return CertificateStatus::Expired {
295 valid_until: identity.valid_until.clone(),
296 now: now.to_string(),
297 };
298 }
299 CertificateStatus::Valid
300}
301
302fn classify_tool_usage(
305 receipt: &SessionReceipt,
306 certificate: &AgentCertificate,
307) -> (Vec<String>, Vec<String>, Vec<String>) {
308 use std::collections::BTreeSet;
309
310 let authorized: BTreeSet<String> = certificate
311 .capabilities
312 .tools
313 .iter()
314 .map(|t| t.name.clone())
315 .collect();
316
317 let called: BTreeSet<String> = receipt
320 .tool_usage
321 .as_ref()
322 .map(|u| u.actual.iter().map(|e| e.tool_name.clone()).collect())
323 .unwrap_or_default();
324
325 let authorized_calls: Vec<String> = called.intersection(&authorized).cloned().collect();
326 let unauthorized_calls: Vec<String> = called.difference(&authorized).cloned().collect();
327 let never_called: Vec<String> = authorized.difference(&called).cloned().collect();
328
329 (authorized_calls, unauthorized_calls, never_called)
330}
331
332#[cfg(test)]
333mod tests {
334 use super::*;
335 use crate::agent::{
336 AgentCapabilities, AgentDeclaration, AgentIdentity, CertificateSignature, ToolCapability,
337 CERTIFICATE_SCHEMA_VERSION, CERTIFICATE_TYPE,
338 };
339 use crate::session::manifest::{LifecycleMode, Participants, SessionStatus};
340 use crate::session::receipt::{SessionReceipt, SessionSection, ToolUsage, ToolUsageEntry};
341 use crate::session::render::RenderConfig;
342 use crate::session::side_effects::SideEffects;
343
344 fn certificate(
345 ship_id: &str,
346 tools: &[&str],
347 issued: &str,
348 valid_until: &str,
349 ) -> AgentCertificate {
350 AgentCertificate {
351 r#type: CERTIFICATE_TYPE.into(),
352 schema_version: Some(CERTIFICATE_SCHEMA_VERSION.into()),
353 identity: AgentIdentity {
354 agent_name: "agent-007".into(),
355 ship_id: ship_id.into(),
356 public_key: "pk_b64".into(),
357 issuer: format!("ship://{ship_id}"),
358 issued_at: issued.into(),
359 valid_until: valid_until.into(),
360 model: None,
361 description: None,
362 },
363 capabilities: AgentCapabilities {
364 tools: tools
365 .iter()
366 .map(|n| ToolCapability {
367 name: (*n).into(),
368 description: None,
369 })
370 .collect(),
371 api_endpoints: vec![],
372 mcp_servers: vec![],
373 },
374 declaration: AgentDeclaration {
375 bounded_actions: tools.iter().map(|s| (*s).into()).collect(),
376 forbidden: vec![],
377 escalation_required: vec![],
378 },
379 signature: CertificateSignature {
380 algorithm: "ed25519".into(),
381 key_id: "key_1".into(),
382 public_key: "pk_b64".into(),
383 signature: "sig_b64".into(),
384 signed_fields: "identity+capabilities+declaration".into(),
385 },
386 }
387 }
388
389 fn receipt(ship_id: Option<&str>, tools_called: &[(&str, u32)]) -> SessionReceipt {
390 let tool_usage = if tools_called.is_empty() {
391 None
392 } else {
393 Some(ToolUsage {
394 declared: vec![],
395 actual: tools_called
396 .iter()
397 .map(|(n, c)| ToolUsageEntry {
398 tool_name: (*n).into(),
399 count: *c,
400 })
401 .collect(),
402 unauthorized: vec![],
403 })
404 };
405 SessionReceipt {
406 type_: crate::session::receipt::RECEIPT_TYPE.into(),
407 schema_version: Some(crate::session::receipt::RECEIPT_SCHEMA_VERSION.into()),
408 session: SessionSection {
409 id: "ssn_test".into(),
410 name: None,
411 mode: LifecycleMode::Manual,
412 started_at: "2026-04-10T00:00:00Z".into(),
413 ended_at: Some("2026-04-10T00:30:00Z".into()),
414 status: SessionStatus::Completed,
415 duration_ms: Some(1_800_000),
416 ship_id: ship_id.map(str::to_string),
417 narrative: None,
418 total_tokens_in: 0,
419 total_tokens_out: 0,
420 },
421 participants: Participants::default(),
422 hosts: vec![],
423 tools: vec![],
424 agent_graph: Default::default(),
425 timeline: vec![],
426 side_effects: SideEffects::default(),
427 artifacts: vec![],
428 proofs: Default::default(),
429 merkle: Default::default(),
430 render: RenderConfig {
431 title: None,
432 theme: None,
433 sections: RenderConfig::default_sections(),
434 generate_preview: true,
435 },
436 tool_usage,
437 }
438 }
439
440 const NOW: &str = "2026-04-18T10:00:00Z";
441 const ISSUED: &str = "2026-04-01T00:00:00Z";
442 const VALID_UNTIL: &str = "2027-04-01T00:00:00Z";
443
444 #[test]
445 fn all_tool_calls_authorized_passes() {
446 let cert = certificate("ship_a", &["Bash", "Read"], ISSUED, VALID_UNTIL);
447 let rec = receipt(Some("ship_a"), &[("Bash", 4), ("Read", 2)]);
448 let r = cross_verify_receipt_and_certificate(&rec, &cert, NOW);
449 assert_eq!(r.ship_id_status, ShipIdStatus::Match);
450 assert_eq!(r.certificate_status, CertificateStatus::Valid);
451 assert_eq!(r.authorized_tool_calls, vec!["Bash", "Read"]);
452 assert!(r.unauthorized_tool_calls.is_empty());
453 assert!(r.authorized_tools_never_called.is_empty());
454 assert!(r.ok());
455 }
456
457 #[test]
458 fn unauthorized_tool_call_flagged_and_blocks_ok() {
459 let cert = certificate("ship_a", &["Read"], ISSUED, VALID_UNTIL);
460 let rec = receipt(Some("ship_a"), &[("Read", 1), ("Write", 1)]);
461 let r = cross_verify_receipt_and_certificate(&rec, &cert, NOW);
462 assert_eq!(r.authorized_tool_calls, vec!["Read"]);
463 assert_eq!(r.unauthorized_tool_calls, vec!["Write"]);
464 assert!(r.authorized_tools_never_called.is_empty());
465 assert!(!r.ok(), "unauthorized call must block ok()");
466 }
467
468 #[test]
469 fn tools_authorized_but_never_called_reported_and_still_ok() {
470 let cert = certificate(
471 "ship_a",
472 &["Bash", "Read", "DropDatabase"],
473 ISSUED,
474 VALID_UNTIL,
475 );
476 let rec = receipt(Some("ship_a"), &[("Bash", 1)]);
477 let r = cross_verify_receipt_and_certificate(&rec, &cert, NOW);
478 assert_eq!(r.authorized_tool_calls, vec!["Bash"]);
479 assert!(r.unauthorized_tool_calls.is_empty());
480 assert_eq!(
481 r.authorized_tools_never_called,
482 vec!["DropDatabase".to_string(), "Read".to_string()]
483 );
484 assert!(r.ok(), "unused authorization is not a failure");
485 }
486
487 #[test]
488 fn mismatched_ship_ids_blocks_ok() {
489 let cert = certificate("ship_a", &["Bash"], ISSUED, VALID_UNTIL);
490 let rec = receipt(Some("ship_b"), &[("Bash", 1)]);
491 let r = cross_verify_receipt_and_certificate(&rec, &cert, NOW);
492 assert_eq!(
493 r.ship_id_status,
494 ShipIdStatus::Mismatch {
495 receipt: "ship_b".into(),
496 certificate: "ship_a".into()
497 }
498 );
499 assert!(!r.ok());
500 }
501
502 #[test]
503 fn expired_certificate_blocks_ok() {
504 let cert = certificate("ship_a", &["Bash"], ISSUED, "2026-04-10T00:00:00Z");
505 let rec = receipt(Some("ship_a"), &[("Bash", 1)]);
506 let r = cross_verify_receipt_and_certificate(&rec, &cert, NOW);
507 assert_eq!(
508 r.certificate_status,
509 CertificateStatus::Expired {
510 valid_until: "2026-04-10T00:00:00Z".into(),
511 now: NOW.into()
512 }
513 );
514 assert!(!r.ok());
515 }
516
517 #[test]
518 fn not_yet_valid_certificate_blocks_ok() {
519 let cert = certificate(
520 "ship_a",
521 &["Bash"],
522 "2027-01-01T00:00:00Z",
523 "2028-01-01T00:00:00Z",
524 );
525 let rec = receipt(Some("ship_a"), &[("Bash", 1)]);
526 let r = cross_verify_receipt_and_certificate(&rec, &cert, NOW);
527 assert!(matches!(
528 r.certificate_status,
529 CertificateStatus::NotYetValid { .. }
530 ));
531 assert!(!r.ok());
532 }
533
534 #[test]
535 fn legacy_receipt_without_ship_id_is_unknown_and_blocks_ok() {
536 let cert = certificate("ship_a", &["Bash"], ISSUED, VALID_UNTIL);
537 let rec = receipt(None, &[("Bash", 1)]); let r = cross_verify_receipt_and_certificate(&rec, &cert, NOW);
539 assert_eq!(r.ship_id_status, ShipIdStatus::Unknown);
540 assert!(!r.ok(), "unknown ship_id must block ok() by default");
541 }
542
543 #[test]
544 fn no_tool_calls_in_receipt_yields_empty_lists() {
545 let cert = certificate("ship_a", &["Bash"], ISSUED, VALID_UNTIL);
546 let rec = receipt(Some("ship_a"), &[]);
547 let r = cross_verify_receipt_and_certificate(&rec, &cert, NOW);
548 assert!(r.authorized_tool_calls.is_empty());
549 assert!(r.unauthorized_tool_calls.is_empty());
550 assert_eq!(r.authorized_tools_never_called, vec!["Bash"]);
551 assert!(r.ok());
552 }
553
554 #[test]
562 fn chain_linkage_check_never_emitted() {
563 use crate::session::receipt::{ArtifactEntry, TimelineEntry};
564
565 let rec_empty = receipt(Some("ship_a"), &[]);
567 let checks_empty = verify_receipt_json_checks(&rec_empty);
568 assert!(
569 !checks_empty.iter().any(|c| c.name == "chain_linkage"),
570 "chain_linkage check must not be emitted (empty receipt). got: {:?}",
571 checks_empty.iter().map(|c| &c.name).collect::<Vec<_>>(),
572 );
573
574 let mut rec_full = receipt(Some("ship_a"), &[]);
577 rec_full.artifacts = vec![
578 ArtifactEntry {
579 artifact_id: "art_aaaa".into(),
580 payload_type: "treeship.dev/v0/action".into(),
581 digest: None,
582 signed_at: None,
583 },
584 ArtifactEntry {
585 artifact_id: "art_bbbb".into(),
586 payload_type: "treeship.dev/v0/action".into(),
587 digest: None,
588 signed_at: None,
589 },
590 ];
591 rec_full.merkle.leaf_count = 2;
592 rec_full.timeline = vec![
593 TimelineEntry {
594 sequence_no: 1,
595 timestamp: "2026-04-10T00:00:01Z".into(),
596 event_id: "evt_1".into(),
597 event_type: "tool.call".into(),
598 agent_instance_id: "ai_1".into(),
599 agent_name: "a".into(),
600 host_id: "h_1".into(),
601 summary: None,
602 },
603 TimelineEntry {
604 sequence_no: 2,
605 timestamp: "2026-04-10T00:00:02Z".into(),
606 event_id: "evt_2".into(),
607 event_type: "tool.call".into(),
608 agent_instance_id: "ai_1".into(),
609 agent_name: "a".into(),
610 host_id: "h_1".into(),
611 summary: None,
612 },
613 ];
614
615 let checks_full = verify_receipt_json_checks(&rec_full);
616 assert!(
617 !checks_full.iter().any(|c| c.name == "chain_linkage"),
618 "chain_linkage check must not be emitted (populated receipt). got: {:?}",
619 checks_full.iter().map(|c| &c.name).collect::<Vec<_>>(),
620 );
621 }
622
623 fn receipt_with_v2_merkle() -> SessionReceipt {
629 use crate::merkle::MerkleTree;
630 use crate::session::receipt::{ArtifactEntry, InclusionProofEntry, MerkleSection};
631
632 let mut tree = MerkleTree::new();
633 tree.append("art_a");
634 tree.append("art_b");
635 let root_bytes = tree.root().unwrap();
636 let inclusion = tree.inclusion_proof(0).unwrap();
637
638 let mut rec = receipt(Some("ship_a"), &[]);
639 rec.artifacts = vec![
640 ArtifactEntry {
641 artifact_id: "art_a".into(),
642 payload_type: "test".into(),
643 digest: None,
644 signed_at: None,
645 },
646 ArtifactEntry {
647 artifact_id: "art_b".into(),
648 payload_type: "test".into(),
649 digest: None,
650 signed_at: None,
651 },
652 ];
653 rec.merkle = MerkleSection {
654 leaf_count: 2,
655 root: Some(format!("mroot_{}", hex::encode(root_bytes))),
656 checkpoint_id: None,
657 inclusion_proofs: vec![InclusionProofEntry {
658 artifact_id: "art_a".into(),
659 leaf_index: 0,
660 proof: inclusion,
661 }],
662 merkle_version: crate::merkle::MERKLE_VERSION_V2,
663 };
664 rec
665 }
666
667 #[test]
668 fn unknown_merkle_version_rejected_at_verify() {
669 let mut rec = receipt_with_v2_merkle();
673 rec.merkle.merkle_version = 99;
674
675 let checks = verify_receipt_json_checks(&rec);
676 let merkle_root = checks
677 .iter()
678 .find(|c| c.name == "merkle_root")
679 .expect("merkle_root check should be emitted");
680 assert_eq!(
681 merkle_root.status,
682 VerifyStatus::Fail,
683 "unknown merkle_version must hard-fail, got: {:?}",
684 merkle_root,
685 );
686 assert!(
687 merkle_root.detail.contains("unknown merkle_version"),
688 "fail message should explain the unknown version, got: {}",
689 merkle_root.detail,
690 );
691 }
692
693 #[test]
694 fn per_proof_version_drift_rejected() {
695 let mut rec = receipt_with_v2_merkle();
699 rec.merkle.inclusion_proofs[0].proof.merkle_version = crate::merkle::MERKLE_VERSION_V1;
700
701 let checks = verify_receipt_json_checks(&rec);
702 let proofs = checks
703 .iter()
704 .find(|c| c.name == "inclusion_proofs")
705 .expect("inclusion_proofs check should be emitted");
706 assert_eq!(
707 proofs.status,
708 VerifyStatus::Fail,
709 "per-proof merkle_version drift must hard-fail, got: {:?}",
710 proofs,
711 );
712 }
713}