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 mut tree = MerkleTree::new();
36 for a in &receipt.artifacts {
37 tree.append(&a.artifact_id);
38 }
39 let root_bytes = tree.root();
40 let recomputed_root = root_bytes.map(|r| format!("mroot_{}", hex::encode(r)));
41 let root_hex = root_bytes.map(hex::encode).unwrap_or_default();
42
43 if recomputed_root == receipt.merkle.root {
44 checks.push(VerifyCheck::pass(
45 "merkle_root",
46 "Merkle root matches recomputed value",
47 ));
48 } else {
49 checks.push(VerifyCheck::fail(
50 "merkle_root",
51 &format!(
52 "recomputed {recomputed_root:?} != receipt {:?}",
53 receipt.merkle.root
54 ),
55 ));
56 }
57
58 let proof_total = receipt.merkle.inclusion_proofs.len();
59 let mut proofs_passed = 0usize;
60 for entry in &receipt.merkle.inclusion_proofs {
61 if MerkleTree::verify_proof(&root_hex, &entry.artifact_id, &entry.proof) {
62 proofs_passed += 1;
63 }
64 }
65 if proofs_passed == proof_total {
66 checks.push(VerifyCheck::pass(
67 "inclusion_proofs",
68 &format!("{proofs_passed}/{proof_total} inclusion proofs passed"),
69 ));
70 } else {
71 checks.push(VerifyCheck::fail(
72 "inclusion_proofs",
73 &format!("{proofs_passed}/{proof_total} inclusion proofs passed"),
74 ));
75 }
76 } else {
77 checks.push(VerifyCheck::warn("merkle_root", "No artifacts to verify"));
78 }
79
80 if receipt.merkle.leaf_count == receipt.artifacts.len() {
81 checks.push(VerifyCheck::pass(
82 "leaf_count",
83 "Leaf count matches artifact count",
84 ));
85 } else {
86 checks.push(VerifyCheck::fail(
87 "leaf_count",
88 &format!(
89 "leaf_count {} != artifact count {}",
90 receipt.merkle.leaf_count,
91 receipt.artifacts.len()
92 ),
93 ));
94 }
95
96 let ordered = receipt.timeline.windows(2).all(|w| {
97 (&w[0].timestamp, w[0].sequence_no, &w[0].event_id)
98 <= (&w[1].timestamp, w[1].sequence_no, &w[1].event_id)
99 });
100 if ordered {
101 checks.push(VerifyCheck::pass(
102 "timeline_order",
103 "Timeline is correctly ordered",
104 ));
105 } else {
106 checks.push(VerifyCheck::fail(
107 "timeline_order",
108 "Timeline entries are not in deterministic order",
109 ));
110 }
111
112 checks.push(VerifyCheck::pass(
113 "chain_linkage",
114 "Receipt-level chain linkage intact",
115 ));
116
117 checks
118}
119
120pub fn checks_ok(checks: &[VerifyCheck]) -> bool {
122 checks.iter().all(|c| c.status != VerifyStatus::Fail)
123}
124
125#[derive(Debug, Clone)]
127pub struct CrossVerifyResult {
128 pub ship_id_status: ShipIdStatus,
130 pub certificate_status: CertificateStatus,
132 pub authorized_tool_calls: Vec<String>,
134 pub unauthorized_tool_calls: Vec<String>,
137 pub authorized_tools_never_called: Vec<String>,
141}
142
143impl CrossVerifyResult {
144 pub fn ok(&self) -> bool {
147 matches!(self.ship_id_status, ShipIdStatus::Match)
148 && matches!(self.certificate_status, CertificateStatus::Valid)
149 && self.unauthorized_tool_calls.is_empty()
150 }
151}
152
153#[derive(Debug, Clone, PartialEq, Eq)]
155pub enum ShipIdStatus {
156 Match,
158 Mismatch {
160 receipt: String,
161 certificate: String,
162 },
163 Unknown,
167}
168
169#[derive(Debug, Clone, PartialEq, Eq)]
171pub enum CertificateStatus {
172 Valid,
173 Expired { valid_until: String, now: String },
175 NotYetValid { issued_at: String, now: String },
177}
178
179pub fn cross_verify_receipt_and_certificate(
186 receipt: &SessionReceipt,
187 certificate: &AgentCertificate,
188 now_rfc3339: &str,
189) -> CrossVerifyResult {
190 let ship_id_status = compare_ship_ids(
191 receipt.session.ship_id.as_deref(),
192 &certificate.identity.ship_id,
193 );
194 let certificate_status = classify_certificate_validity(certificate, now_rfc3339);
195 let (authorized_tool_calls, unauthorized_tool_calls, authorized_tools_never_called) =
196 classify_tool_usage(receipt, certificate);
197
198 CrossVerifyResult {
199 ship_id_status,
200 certificate_status,
201 authorized_tool_calls,
202 unauthorized_tool_calls,
203 authorized_tools_never_called,
204 }
205}
206
207fn compare_ship_ids(receipt: Option<&str>, certificate: &str) -> ShipIdStatus {
208 match receipt {
209 Some(r) if r == certificate => ShipIdStatus::Match,
210 Some(r) => ShipIdStatus::Mismatch {
211 receipt: r.to_string(),
212 certificate: certificate.to_string(),
213 },
214 None => ShipIdStatus::Unknown,
215 }
216}
217
218fn classify_certificate_validity(
219 certificate: &AgentCertificate,
220 now: &str,
221) -> CertificateStatus {
222 let identity = &certificate.identity;
226 if now < identity.issued_at.as_str() {
227 return CertificateStatus::NotYetValid {
228 issued_at: identity.issued_at.clone(),
229 now: now.to_string(),
230 };
231 }
232 if now > identity.valid_until.as_str() {
233 return CertificateStatus::Expired {
234 valid_until: identity.valid_until.clone(),
235 now: now.to_string(),
236 };
237 }
238 CertificateStatus::Valid
239}
240
241fn classify_tool_usage(
244 receipt: &SessionReceipt,
245 certificate: &AgentCertificate,
246) -> (Vec<String>, Vec<String>, Vec<String>) {
247 use std::collections::BTreeSet;
248
249 let authorized: BTreeSet<String> = certificate
250 .capabilities
251 .tools
252 .iter()
253 .map(|t| t.name.clone())
254 .collect();
255
256 let called: BTreeSet<String> = receipt
259 .tool_usage
260 .as_ref()
261 .map(|u| u.actual.iter().map(|e| e.tool_name.clone()).collect())
262 .unwrap_or_default();
263
264 let authorized_calls: Vec<String> =
265 called.intersection(&authorized).cloned().collect();
266 let unauthorized_calls: Vec<String> =
267 called.difference(&authorized).cloned().collect();
268 let never_called: Vec<String> = authorized
269 .difference(&called)
270 .cloned()
271 .collect();
272
273 (authorized_calls, unauthorized_calls, never_called)
274}
275
276#[cfg(test)]
277mod tests {
278 use super::*;
279 use crate::agent::{
280 AgentCapabilities, AgentDeclaration, AgentIdentity, CertificateSignature,
281 ToolCapability, CERTIFICATE_SCHEMA_VERSION, CERTIFICATE_TYPE,
282 };
283 use crate::session::manifest::{LifecycleMode, Participants, SessionStatus};
284 use crate::session::receipt::{SessionReceipt, SessionSection, ToolUsage, ToolUsageEntry};
285 use crate::session::render::RenderConfig;
286 use crate::session::side_effects::SideEffects;
287
288 fn certificate(ship_id: &str, tools: &[&str], issued: &str, valid_until: &str) -> AgentCertificate {
289 AgentCertificate {
290 r#type: CERTIFICATE_TYPE.into(),
291 schema_version: Some(CERTIFICATE_SCHEMA_VERSION.into()),
292 identity: AgentIdentity {
293 agent_name: "agent-007".into(),
294 ship_id: ship_id.into(),
295 public_key: "pk_b64".into(),
296 issuer: format!("ship://{ship_id}"),
297 issued_at: issued.into(),
298 valid_until: valid_until.into(),
299 model: None,
300 description: None,
301 },
302 capabilities: AgentCapabilities {
303 tools: tools
304 .iter()
305 .map(|n| ToolCapability { name: (*n).into(), description: None })
306 .collect(),
307 api_endpoints: vec![],
308 mcp_servers: vec![],
309 },
310 declaration: AgentDeclaration {
311 bounded_actions: tools.iter().map(|s| (*s).into()).collect(),
312 forbidden: vec![],
313 escalation_required: vec![],
314 },
315 signature: CertificateSignature {
316 algorithm: "ed25519".into(),
317 key_id: "key_1".into(),
318 public_key: "pk_b64".into(),
319 signature: "sig_b64".into(),
320 signed_fields: "identity+capabilities+declaration".into(),
321 },
322 }
323 }
324
325 fn receipt(ship_id: Option<&str>, tools_called: &[(&str, u32)]) -> SessionReceipt {
326 let tool_usage = if tools_called.is_empty() {
327 None
328 } else {
329 Some(ToolUsage {
330 declared: vec![],
331 actual: tools_called
332 .iter()
333 .map(|(n, c)| ToolUsageEntry { tool_name: (*n).into(), count: *c })
334 .collect(),
335 unauthorized: vec![],
336 })
337 };
338 SessionReceipt {
339 type_: crate::session::receipt::RECEIPT_TYPE.into(),
340 schema_version: Some(crate::session::receipt::RECEIPT_SCHEMA_VERSION.into()),
341 session: SessionSection {
342 id: "ssn_test".into(),
343 name: None,
344 mode: LifecycleMode::Manual,
345 started_at: "2026-04-10T00:00:00Z".into(),
346 ended_at: Some("2026-04-10T00:30:00Z".into()),
347 status: SessionStatus::Completed,
348 duration_ms: Some(1_800_000),
349 ship_id: ship_id.map(str::to_string),
350 narrative: None,
351 total_tokens_in: 0,
352 total_tokens_out: 0,
353 },
354 participants: Participants::default(),
355 hosts: vec![],
356 tools: vec![],
357 agent_graph: Default::default(),
358 timeline: vec![],
359 side_effects: SideEffects::default(),
360 artifacts: vec![],
361 proofs: Default::default(),
362 merkle: Default::default(),
363 render: RenderConfig {
364 title: None,
365 theme: None,
366 sections: RenderConfig::default_sections(),
367 generate_preview: true,
368 },
369 tool_usage,
370 }
371 }
372
373 const NOW: &str = "2026-04-18T10:00:00Z";
374 const ISSUED: &str = "2026-04-01T00:00:00Z";
375 const VALID_UNTIL: &str = "2027-04-01T00:00:00Z";
376
377 #[test]
378 fn all_tool_calls_authorized_passes() {
379 let cert = certificate("ship_a", &["Bash", "Read"], ISSUED, VALID_UNTIL);
380 let rec = receipt(Some("ship_a"), &[("Bash", 4), ("Read", 2)]);
381 let r = cross_verify_receipt_and_certificate(&rec, &cert, NOW);
382 assert_eq!(r.ship_id_status, ShipIdStatus::Match);
383 assert_eq!(r.certificate_status, CertificateStatus::Valid);
384 assert_eq!(r.authorized_tool_calls, vec!["Bash", "Read"]);
385 assert!(r.unauthorized_tool_calls.is_empty());
386 assert!(r.authorized_tools_never_called.is_empty());
387 assert!(r.ok());
388 }
389
390 #[test]
391 fn unauthorized_tool_call_flagged_and_blocks_ok() {
392 let cert = certificate("ship_a", &["Read"], ISSUED, VALID_UNTIL);
393 let rec = receipt(Some("ship_a"), &[("Read", 1), ("Write", 1)]);
394 let r = cross_verify_receipt_and_certificate(&rec, &cert, NOW);
395 assert_eq!(r.authorized_tool_calls, vec!["Read"]);
396 assert_eq!(r.unauthorized_tool_calls, vec!["Write"]);
397 assert!(r.authorized_tools_never_called.is_empty());
398 assert!(!r.ok(), "unauthorized call must block ok()");
399 }
400
401 #[test]
402 fn tools_authorized_but_never_called_reported_and_still_ok() {
403 let cert = certificate("ship_a", &["Bash", "Read", "DropDatabase"], ISSUED, VALID_UNTIL);
404 let rec = receipt(Some("ship_a"), &[("Bash", 1)]);
405 let r = cross_verify_receipt_and_certificate(&rec, &cert, NOW);
406 assert_eq!(r.authorized_tool_calls, vec!["Bash"]);
407 assert!(r.unauthorized_tool_calls.is_empty());
408 assert_eq!(
409 r.authorized_tools_never_called,
410 vec!["DropDatabase".to_string(), "Read".to_string()]
411 );
412 assert!(r.ok(), "unused authorization is not a failure");
413 }
414
415 #[test]
416 fn mismatched_ship_ids_blocks_ok() {
417 let cert = certificate("ship_a", &["Bash"], ISSUED, VALID_UNTIL);
418 let rec = receipt(Some("ship_b"), &[("Bash", 1)]);
419 let r = cross_verify_receipt_and_certificate(&rec, &cert, NOW);
420 assert_eq!(
421 r.ship_id_status,
422 ShipIdStatus::Mismatch {
423 receipt: "ship_b".into(),
424 certificate: "ship_a".into()
425 }
426 );
427 assert!(!r.ok());
428 }
429
430 #[test]
431 fn expired_certificate_blocks_ok() {
432 let cert = certificate("ship_a", &["Bash"], ISSUED, "2026-04-10T00:00:00Z");
433 let rec = receipt(Some("ship_a"), &[("Bash", 1)]);
434 let r = cross_verify_receipt_and_certificate(&rec, &cert, NOW);
435 assert_eq!(
436 r.certificate_status,
437 CertificateStatus::Expired {
438 valid_until: "2026-04-10T00:00:00Z".into(),
439 now: NOW.into()
440 }
441 );
442 assert!(!r.ok());
443 }
444
445 #[test]
446 fn not_yet_valid_certificate_blocks_ok() {
447 let cert = certificate("ship_a", &["Bash"], "2027-01-01T00:00:00Z", "2028-01-01T00:00:00Z");
448 let rec = receipt(Some("ship_a"), &[("Bash", 1)]);
449 let r = cross_verify_receipt_and_certificate(&rec, &cert, NOW);
450 assert!(matches!(
451 r.certificate_status,
452 CertificateStatus::NotYetValid { .. }
453 ));
454 assert!(!r.ok());
455 }
456
457 #[test]
458 fn legacy_receipt_without_ship_id_is_unknown_and_blocks_ok() {
459 let cert = certificate("ship_a", &["Bash"], ISSUED, VALID_UNTIL);
460 let rec = receipt(None, &[("Bash", 1)]); let r = cross_verify_receipt_and_certificate(&rec, &cert, NOW);
462 assert_eq!(r.ship_id_status, ShipIdStatus::Unknown);
463 assert!(!r.ok(), "unknown ship_id must block ok() by default");
464 }
465
466 #[test]
467 fn no_tool_calls_in_receipt_yields_empty_lists() {
468 let cert = certificate("ship_a", &["Bash"], ISSUED, VALID_UNTIL);
469 let rec = receipt(Some("ship_a"), &[]);
470 let r = cross_verify_receipt_and_certificate(&rec, &cert, NOW);
471 assert!(r.authorized_tool_calls.is_empty());
472 assert!(r.unauthorized_tool_calls.is_empty());
473 assert_eq!(r.authorized_tools_never_called, vec!["Bash"]);
474 assert!(r.ok());
475 }
476}