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