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