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