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