1use crate::agent::AgentCertificate;
17use crate::session::receipt::SessionReceipt;
18use crate::session::package::{VerifyCheck, VerifyStatus};
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!(
94 "per-proof merkle_version drift detected (section declares v{version})",
95 ),
96 ));
97 } else if proofs_passed == proof_total {
98 checks.push(VerifyCheck::pass(
99 "inclusion_proofs",
100 &format!("{proofs_passed}/{proof_total} inclusion proofs passed"),
101 ));
102 } else {
103 checks.push(VerifyCheck::fail(
104 "inclusion_proofs",
105 &format!("{proofs_passed}/{proof_total} inclusion proofs passed"),
106 ));
107 }
108 } else {
109 checks.push(VerifyCheck::warn("merkle_root", "No artifacts to verify"));
110 }
111
112 finish_with_leaf_count_and_timeline(receipt, checks)
113}
114
115fn finish_with_leaf_count_and_timeline(
119 receipt: &SessionReceipt,
120 mut checks: Vec<VerifyCheck>,
121) -> Vec<VerifyCheck> {
122 if receipt.merkle.leaf_count == receipt.artifacts.len() {
123 checks.push(VerifyCheck::pass(
124 "leaf_count",
125 "Leaf count matches artifact count",
126 ));
127 } else {
128 checks.push(VerifyCheck::fail(
129 "leaf_count",
130 &format!(
131 "leaf_count {} != artifact count {}",
132 receipt.merkle.leaf_count,
133 receipt.artifacts.len()
134 ),
135 ));
136 }
137
138 let ordered = receipt.timeline.windows(2).all(|w| {
139 (&w[0].timestamp, w[0].sequence_no, &w[0].event_id)
140 <= (&w[1].timestamp, w[1].sequence_no, &w[1].event_id)
141 });
142 if ordered {
143 checks.push(VerifyCheck::pass(
144 "timeline_order",
145 "Timeline is correctly ordered",
146 ));
147 } else {
148 checks.push(VerifyCheck::fail(
149 "timeline_order",
150 "Timeline entries are not in deterministic order",
151 ));
152 }
153
154 checks
168}
169
170pub fn checks_ok(checks: &[VerifyCheck]) -> bool {
172 checks.iter().all(|c| c.status != VerifyStatus::Fail)
173}
174
175#[derive(Debug, Clone)]
177pub struct CrossVerifyResult {
178 pub ship_id_status: ShipIdStatus,
180 pub certificate_status: CertificateStatus,
182 pub authorized_tool_calls: Vec<String>,
184 pub unauthorized_tool_calls: Vec<String>,
187 pub authorized_tools_never_called: Vec<String>,
191}
192
193impl CrossVerifyResult {
194 pub fn ok(&self) -> bool {
197 matches!(self.ship_id_status, ShipIdStatus::Match)
198 && matches!(self.certificate_status, CertificateStatus::Valid)
199 && self.unauthorized_tool_calls.is_empty()
200 }
201}
202
203#[derive(Debug, Clone, PartialEq, Eq)]
205pub enum ShipIdStatus {
206 Match,
208 Mismatch {
210 receipt: String,
211 certificate: String,
212 },
213 Unknown,
217}
218
219#[derive(Debug, Clone, PartialEq, Eq)]
221pub enum CertificateStatus {
222 Valid,
223 Expired { valid_until: String, now: String },
225 NotYetValid { issued_at: String, now: String },
227}
228
229pub fn cross_verify_receipt_and_certificate(
236 receipt: &SessionReceipt,
237 certificate: &AgentCertificate,
238 now_rfc3339: &str,
239) -> CrossVerifyResult {
240 let ship_id_status = compare_ship_ids(
241 receipt.session.ship_id.as_deref(),
242 &certificate.identity.ship_id,
243 );
244 let certificate_status = classify_certificate_validity(certificate, now_rfc3339);
245 let (authorized_tool_calls, unauthorized_tool_calls, authorized_tools_never_called) =
246 classify_tool_usage(receipt, certificate);
247
248 CrossVerifyResult {
249 ship_id_status,
250 certificate_status,
251 authorized_tool_calls,
252 unauthorized_tool_calls,
253 authorized_tools_never_called,
254 }
255}
256
257fn compare_ship_ids(receipt: Option<&str>, certificate: &str) -> ShipIdStatus {
258 match receipt {
259 Some(r) if r == certificate => ShipIdStatus::Match,
260 Some(r) => ShipIdStatus::Mismatch {
261 receipt: r.to_string(),
262 certificate: certificate.to_string(),
263 },
264 None => ShipIdStatus::Unknown,
265 }
266}
267
268fn classify_certificate_validity(
269 certificate: &AgentCertificate,
270 now: &str,
271) -> CertificateStatus {
272 let identity = &certificate.identity;
276 if now < identity.issued_at.as_str() {
277 return CertificateStatus::NotYetValid {
278 issued_at: identity.issued_at.clone(),
279 now: now.to_string(),
280 };
281 }
282 if now > identity.valid_until.as_str() {
283 return CertificateStatus::Expired {
284 valid_until: identity.valid_until.clone(),
285 now: now.to_string(),
286 };
287 }
288 CertificateStatus::Valid
289}
290
291fn classify_tool_usage(
294 receipt: &SessionReceipt,
295 certificate: &AgentCertificate,
296) -> (Vec<String>, Vec<String>, Vec<String>) {
297 use std::collections::BTreeSet;
298
299 let authorized: BTreeSet<String> = certificate
300 .capabilities
301 .tools
302 .iter()
303 .map(|t| t.name.clone())
304 .collect();
305
306 let called: BTreeSet<String> = receipt
309 .tool_usage
310 .as_ref()
311 .map(|u| u.actual.iter().map(|e| e.tool_name.clone()).collect())
312 .unwrap_or_default();
313
314 let authorized_calls: Vec<String> =
315 called.intersection(&authorized).cloned().collect();
316 let unauthorized_calls: Vec<String> =
317 called.difference(&authorized).cloned().collect();
318 let never_called: Vec<String> = authorized
319 .difference(&called)
320 .cloned()
321 .collect();
322
323 (authorized_calls, unauthorized_calls, never_called)
324}
325
326#[cfg(test)]
327mod tests {
328 use super::*;
329 use crate::agent::{
330 AgentCapabilities, AgentDeclaration, AgentIdentity, CertificateSignature,
331 ToolCapability, CERTIFICATE_SCHEMA_VERSION, CERTIFICATE_TYPE,
332 };
333 use crate::session::manifest::{LifecycleMode, Participants, SessionStatus};
334 use crate::session::receipt::{SessionReceipt, SessionSection, ToolUsage, ToolUsageEntry};
335 use crate::session::render::RenderConfig;
336 use crate::session::side_effects::SideEffects;
337
338 fn certificate(ship_id: &str, tools: &[&str], issued: &str, valid_until: &str) -> AgentCertificate {
339 AgentCertificate {
340 r#type: CERTIFICATE_TYPE.into(),
341 schema_version: Some(CERTIFICATE_SCHEMA_VERSION.into()),
342 identity: AgentIdentity {
343 agent_name: "agent-007".into(),
344 ship_id: ship_id.into(),
345 public_key: "pk_b64".into(),
346 issuer: format!("ship://{ship_id}"),
347 issued_at: issued.into(),
348 valid_until: valid_until.into(),
349 model: None,
350 description: None,
351 },
352 capabilities: AgentCapabilities {
353 tools: tools
354 .iter()
355 .map(|n| ToolCapability { name: (*n).into(), description: None })
356 .collect(),
357 api_endpoints: vec![],
358 mcp_servers: vec![],
359 },
360 declaration: AgentDeclaration {
361 bounded_actions: tools.iter().map(|s| (*s).into()).collect(),
362 forbidden: vec![],
363 escalation_required: vec![],
364 },
365 signature: CertificateSignature {
366 algorithm: "ed25519".into(),
367 key_id: "key_1".into(),
368 public_key: "pk_b64".into(),
369 signature: "sig_b64".into(),
370 signed_fields: "identity+capabilities+declaration".into(),
371 },
372 }
373 }
374
375 fn receipt(ship_id: Option<&str>, tools_called: &[(&str, u32)]) -> SessionReceipt {
376 let tool_usage = if tools_called.is_empty() {
377 None
378 } else {
379 Some(ToolUsage {
380 declared: vec![],
381 actual: tools_called
382 .iter()
383 .map(|(n, c)| ToolUsageEntry { tool_name: (*n).into(), count: *c })
384 .collect(),
385 unauthorized: vec![],
386 })
387 };
388 SessionReceipt {
389 type_: crate::session::receipt::RECEIPT_TYPE.into(),
390 schema_version: Some(crate::session::receipt::RECEIPT_SCHEMA_VERSION.into()),
391 session: SessionSection {
392 id: "ssn_test".into(),
393 name: None,
394 mode: LifecycleMode::Manual,
395 started_at: "2026-04-10T00:00:00Z".into(),
396 ended_at: Some("2026-04-10T00:30:00Z".into()),
397 status: SessionStatus::Completed,
398 duration_ms: Some(1_800_000),
399 ship_id: ship_id.map(str::to_string),
400 narrative: None,
401 total_tokens_in: 0,
402 total_tokens_out: 0,
403 },
404 participants: Participants::default(),
405 hosts: vec![],
406 tools: vec![],
407 agent_graph: Default::default(),
408 timeline: vec![],
409 side_effects: SideEffects::default(),
410 artifacts: vec![],
411 proofs: Default::default(),
412 merkle: Default::default(),
413 render: RenderConfig {
414 title: None,
415 theme: None,
416 sections: RenderConfig::default_sections(),
417 generate_preview: true,
418 },
419 tool_usage,
420 }
421 }
422
423 const NOW: &str = "2026-04-18T10:00:00Z";
424 const ISSUED: &str = "2026-04-01T00:00:00Z";
425 const VALID_UNTIL: &str = "2027-04-01T00:00:00Z";
426
427 #[test]
428 fn all_tool_calls_authorized_passes() {
429 let cert = certificate("ship_a", &["Bash", "Read"], ISSUED, VALID_UNTIL);
430 let rec = receipt(Some("ship_a"), &[("Bash", 4), ("Read", 2)]);
431 let r = cross_verify_receipt_and_certificate(&rec, &cert, NOW);
432 assert_eq!(r.ship_id_status, ShipIdStatus::Match);
433 assert_eq!(r.certificate_status, CertificateStatus::Valid);
434 assert_eq!(r.authorized_tool_calls, vec!["Bash", "Read"]);
435 assert!(r.unauthorized_tool_calls.is_empty());
436 assert!(r.authorized_tools_never_called.is_empty());
437 assert!(r.ok());
438 }
439
440 #[test]
441 fn unauthorized_tool_call_flagged_and_blocks_ok() {
442 let cert = certificate("ship_a", &["Read"], ISSUED, VALID_UNTIL);
443 let rec = receipt(Some("ship_a"), &[("Read", 1), ("Write", 1)]);
444 let r = cross_verify_receipt_and_certificate(&rec, &cert, NOW);
445 assert_eq!(r.authorized_tool_calls, vec!["Read"]);
446 assert_eq!(r.unauthorized_tool_calls, vec!["Write"]);
447 assert!(r.authorized_tools_never_called.is_empty());
448 assert!(!r.ok(), "unauthorized call must block ok()");
449 }
450
451 #[test]
452 fn tools_authorized_but_never_called_reported_and_still_ok() {
453 let cert = certificate("ship_a", &["Bash", "Read", "DropDatabase"], ISSUED, VALID_UNTIL);
454 let rec = receipt(Some("ship_a"), &[("Bash", 1)]);
455 let r = cross_verify_receipt_and_certificate(&rec, &cert, NOW);
456 assert_eq!(r.authorized_tool_calls, vec!["Bash"]);
457 assert!(r.unauthorized_tool_calls.is_empty());
458 assert_eq!(
459 r.authorized_tools_never_called,
460 vec!["DropDatabase".to_string(), "Read".to_string()]
461 );
462 assert!(r.ok(), "unused authorization is not a failure");
463 }
464
465 #[test]
466 fn mismatched_ship_ids_blocks_ok() {
467 let cert = certificate("ship_a", &["Bash"], ISSUED, VALID_UNTIL);
468 let rec = receipt(Some("ship_b"), &[("Bash", 1)]);
469 let r = cross_verify_receipt_and_certificate(&rec, &cert, NOW);
470 assert_eq!(
471 r.ship_id_status,
472 ShipIdStatus::Mismatch {
473 receipt: "ship_b".into(),
474 certificate: "ship_a".into()
475 }
476 );
477 assert!(!r.ok());
478 }
479
480 #[test]
481 fn expired_certificate_blocks_ok() {
482 let cert = certificate("ship_a", &["Bash"], ISSUED, "2026-04-10T00:00:00Z");
483 let rec = receipt(Some("ship_a"), &[("Bash", 1)]);
484 let r = cross_verify_receipt_and_certificate(&rec, &cert, NOW);
485 assert_eq!(
486 r.certificate_status,
487 CertificateStatus::Expired {
488 valid_until: "2026-04-10T00:00:00Z".into(),
489 now: NOW.into()
490 }
491 );
492 assert!(!r.ok());
493 }
494
495 #[test]
496 fn not_yet_valid_certificate_blocks_ok() {
497 let cert = certificate("ship_a", &["Bash"], "2027-01-01T00:00:00Z", "2028-01-01T00:00:00Z");
498 let rec = receipt(Some("ship_a"), &[("Bash", 1)]);
499 let r = cross_verify_receipt_and_certificate(&rec, &cert, NOW);
500 assert!(matches!(
501 r.certificate_status,
502 CertificateStatus::NotYetValid { .. }
503 ));
504 assert!(!r.ok());
505 }
506
507 #[test]
508 fn legacy_receipt_without_ship_id_is_unknown_and_blocks_ok() {
509 let cert = certificate("ship_a", &["Bash"], ISSUED, VALID_UNTIL);
510 let rec = receipt(None, &[("Bash", 1)]); let r = cross_verify_receipt_and_certificate(&rec, &cert, NOW);
512 assert_eq!(r.ship_id_status, ShipIdStatus::Unknown);
513 assert!(!r.ok(), "unknown ship_id must block ok() by default");
514 }
515
516 #[test]
517 fn no_tool_calls_in_receipt_yields_empty_lists() {
518 let cert = certificate("ship_a", &["Bash"], ISSUED, VALID_UNTIL);
519 let rec = receipt(Some("ship_a"), &[]);
520 let r = cross_verify_receipt_and_certificate(&rec, &cert, NOW);
521 assert!(r.authorized_tool_calls.is_empty());
522 assert!(r.unauthorized_tool_calls.is_empty());
523 assert_eq!(r.authorized_tools_never_called, vec!["Bash"]);
524 assert!(r.ok());
525 }
526
527 #[test]
535 fn chain_linkage_check_never_emitted() {
536 use crate::session::receipt::{ArtifactEntry, TimelineEntry};
537
538 let rec_empty = receipt(Some("ship_a"), &[]);
540 let checks_empty = verify_receipt_json_checks(&rec_empty);
541 assert!(
542 !checks_empty.iter().any(|c| c.name == "chain_linkage"),
543 "chain_linkage check must not be emitted (empty receipt). got: {:?}",
544 checks_empty.iter().map(|c| &c.name).collect::<Vec<_>>(),
545 );
546
547 let mut rec_full = receipt(Some("ship_a"), &[]);
550 rec_full.artifacts = vec![
551 ArtifactEntry {
552 artifact_id: "art_aaaa".into(),
553 payload_type: "treeship.dev/v0/action".into(),
554 digest: None,
555 signed_at: None,
556 },
557 ArtifactEntry {
558 artifact_id: "art_bbbb".into(),
559 payload_type: "treeship.dev/v0/action".into(),
560 digest: None,
561 signed_at: None,
562 },
563 ];
564 rec_full.merkle.leaf_count = 2;
565 rec_full.timeline = vec![
566 TimelineEntry {
567 sequence_no: 1,
568 timestamp: "2026-04-10T00:00:01Z".into(),
569 event_id: "evt_1".into(),
570 event_type: "tool.call".into(),
571 agent_instance_id: "ai_1".into(),
572 agent_name: "a".into(),
573 host_id: "h_1".into(),
574 summary: None,
575 },
576 TimelineEntry {
577 sequence_no: 2,
578 timestamp: "2026-04-10T00:00:02Z".into(),
579 event_id: "evt_2".into(),
580 event_type: "tool.call".into(),
581 agent_instance_id: "ai_1".into(),
582 agent_name: "a".into(),
583 host_id: "h_1".into(),
584 summary: None,
585 },
586 ];
587
588 let checks_full = verify_receipt_json_checks(&rec_full);
589 assert!(
590 !checks_full.iter().any(|c| c.name == "chain_linkage"),
591 "chain_linkage check must not be emitted (populated receipt). got: {:?}",
592 checks_full.iter().map(|c| &c.name).collect::<Vec<_>>(),
593 );
594 }
595
596 fn receipt_with_v2_merkle() -> SessionReceipt {
602 use crate::merkle::MerkleTree;
603 use crate::session::receipt::{
604 ArtifactEntry, InclusionProofEntry, MerkleSection,
605 };
606
607 let mut tree = MerkleTree::new();
608 tree.append("art_a");
609 tree.append("art_b");
610 let root_bytes = tree.root().unwrap();
611 let inclusion = tree.inclusion_proof(0).unwrap();
612
613 let mut rec = receipt(Some("ship_a"), &[]);
614 rec.artifacts = vec![
615 ArtifactEntry {
616 artifact_id: "art_a".into(),
617 payload_type: "test".into(),
618 digest: None,
619 signed_at: None,
620 },
621 ArtifactEntry {
622 artifact_id: "art_b".into(),
623 payload_type: "test".into(),
624 digest: None,
625 signed_at: None,
626 },
627 ];
628 rec.merkle = MerkleSection {
629 leaf_count: 2,
630 root: Some(format!("mroot_{}", hex::encode(root_bytes))),
631 checkpoint_id: None,
632 inclusion_proofs: vec![InclusionProofEntry {
633 artifact_id: "art_a".into(),
634 leaf_index: 0,
635 proof: inclusion,
636 }],
637 merkle_version: crate::merkle::MERKLE_VERSION_V2,
638 };
639 rec
640 }
641
642 #[test]
643 fn unknown_merkle_version_rejected_at_verify() {
644 let mut rec = receipt_with_v2_merkle();
648 rec.merkle.merkle_version = 99;
649
650 let checks = verify_receipt_json_checks(&rec);
651 let merkle_root = checks
652 .iter()
653 .find(|c| c.name == "merkle_root")
654 .expect("merkle_root check should be emitted");
655 assert_eq!(
656 merkle_root.status,
657 VerifyStatus::Fail,
658 "unknown merkle_version must hard-fail, got: {:?}",
659 merkle_root,
660 );
661 assert!(
662 merkle_root.detail.contains("unknown merkle_version"),
663 "fail message should explain the unknown version, got: {}",
664 merkle_root.detail,
665 );
666 }
667
668 #[test]
669 fn per_proof_version_drift_rejected() {
670 let mut rec = receipt_with_v2_merkle();
674 rec.merkle.inclusion_proofs[0].proof.merkle_version = crate::merkle::MERKLE_VERSION_V1;
675
676 let checks = verify_receipt_json_checks(&rec);
677 let proofs = checks
678 .iter()
679 .find(|c| c.name == "inclusion_proofs")
680 .expect("inclusion_proofs check should be emitted");
681 assert_eq!(
682 proofs.status,
683 VerifyStatus::Fail,
684 "per-proof merkle_version drift must hard-fail, got: {:?}",
685 proofs,
686 );
687 }
688}