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