use crate::agent::AgentCertificate;
use crate::session::receipt::SessionReceipt;
use crate::session::package::{VerifyCheck, VerifyStatus};
pub fn verify_receipt_json_checks(receipt: &SessionReceipt) -> Vec<VerifyCheck> {
use crate::merkle::MerkleTree;
let mut checks: Vec<VerifyCheck> = Vec::new();
if !receipt.artifacts.is_empty() {
let mut tree = MerkleTree::new();
for a in &receipt.artifacts {
tree.append(&a.artifact_id);
}
let root_bytes = tree.root();
let recomputed_root = root_bytes.map(|r| format!("mroot_{}", hex::encode(r)));
let root_hex = root_bytes.map(hex::encode).unwrap_or_default();
if recomputed_root == receipt.merkle.root {
checks.push(VerifyCheck::pass(
"merkle_root",
"Merkle root matches recomputed value",
));
} else {
checks.push(VerifyCheck::fail(
"merkle_root",
&format!(
"recomputed {recomputed_root:?} != receipt {:?}",
receipt.merkle.root
),
));
}
let proof_total = receipt.merkle.inclusion_proofs.len();
let mut proofs_passed = 0usize;
for entry in &receipt.merkle.inclusion_proofs {
if MerkleTree::verify_proof(&root_hex, &entry.artifact_id, &entry.proof) {
proofs_passed += 1;
}
}
if proofs_passed == proof_total {
checks.push(VerifyCheck::pass(
"inclusion_proofs",
&format!("{proofs_passed}/{proof_total} inclusion proofs passed"),
));
} else {
checks.push(VerifyCheck::fail(
"inclusion_proofs",
&format!("{proofs_passed}/{proof_total} inclusion proofs passed"),
));
}
} else {
checks.push(VerifyCheck::warn("merkle_root", "No artifacts to verify"));
}
if receipt.merkle.leaf_count == receipt.artifacts.len() {
checks.push(VerifyCheck::pass(
"leaf_count",
"Leaf count matches artifact count",
));
} else {
checks.push(VerifyCheck::fail(
"leaf_count",
&format!(
"leaf_count {} != artifact count {}",
receipt.merkle.leaf_count,
receipt.artifacts.len()
),
));
}
let ordered = receipt.timeline.windows(2).all(|w| {
(&w[0].timestamp, w[0].sequence_no, &w[0].event_id)
<= (&w[1].timestamp, w[1].sequence_no, &w[1].event_id)
});
if ordered {
checks.push(VerifyCheck::pass(
"timeline_order",
"Timeline is correctly ordered",
));
} else {
checks.push(VerifyCheck::fail(
"timeline_order",
"Timeline entries are not in deterministic order",
));
}
checks.push(VerifyCheck::pass(
"chain_linkage",
"Receipt-level chain linkage intact",
));
checks
}
pub fn checks_ok(checks: &[VerifyCheck]) -> bool {
checks.iter().all(|c| c.status != VerifyStatus::Fail)
}
#[derive(Debug, Clone)]
pub struct CrossVerifyResult {
pub ship_id_status: ShipIdStatus,
pub certificate_status: CertificateStatus,
pub authorized_tool_calls: Vec<String>,
pub unauthorized_tool_calls: Vec<String>,
pub authorized_tools_never_called: Vec<String>,
}
impl CrossVerifyResult {
pub fn ok(&self) -> bool {
matches!(self.ship_id_status, ShipIdStatus::Match)
&& matches!(self.certificate_status, CertificateStatus::Valid)
&& self.unauthorized_tool_calls.is_empty()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ShipIdStatus {
Match,
Mismatch {
receipt: String,
certificate: String,
},
Unknown,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CertificateStatus {
Valid,
Expired { valid_until: String, now: String },
NotYetValid { issued_at: String, now: String },
}
pub fn cross_verify_receipt_and_certificate(
receipt: &SessionReceipt,
certificate: &AgentCertificate,
now_rfc3339: &str,
) -> CrossVerifyResult {
let ship_id_status = compare_ship_ids(
receipt.session.ship_id.as_deref(),
&certificate.identity.ship_id,
);
let certificate_status = classify_certificate_validity(certificate, now_rfc3339);
let (authorized_tool_calls, unauthorized_tool_calls, authorized_tools_never_called) =
classify_tool_usage(receipt, certificate);
CrossVerifyResult {
ship_id_status,
certificate_status,
authorized_tool_calls,
unauthorized_tool_calls,
authorized_tools_never_called,
}
}
fn compare_ship_ids(receipt: Option<&str>, certificate: &str) -> ShipIdStatus {
match receipt {
Some(r) if r == certificate => ShipIdStatus::Match,
Some(r) => ShipIdStatus::Mismatch {
receipt: r.to_string(),
certificate: certificate.to_string(),
},
None => ShipIdStatus::Unknown,
}
}
fn classify_certificate_validity(
certificate: &AgentCertificate,
now: &str,
) -> CertificateStatus {
let identity = &certificate.identity;
if now < identity.issued_at.as_str() {
return CertificateStatus::NotYetValid {
issued_at: identity.issued_at.clone(),
now: now.to_string(),
};
}
if now > identity.valid_until.as_str() {
return CertificateStatus::Expired {
valid_until: identity.valid_until.clone(),
now: now.to_string(),
};
}
CertificateStatus::Valid
}
fn classify_tool_usage(
receipt: &SessionReceipt,
certificate: &AgentCertificate,
) -> (Vec<String>, Vec<String>, Vec<String>) {
use std::collections::BTreeSet;
let authorized: BTreeSet<String> = certificate
.capabilities
.tools
.iter()
.map(|t| t.name.clone())
.collect();
let called: BTreeSet<String> = receipt
.tool_usage
.as_ref()
.map(|u| u.actual.iter().map(|e| e.tool_name.clone()).collect())
.unwrap_or_default();
let authorized_calls: Vec<String> =
called.intersection(&authorized).cloned().collect();
let unauthorized_calls: Vec<String> =
called.difference(&authorized).cloned().collect();
let never_called: Vec<String> = authorized
.difference(&called)
.cloned()
.collect();
(authorized_calls, unauthorized_calls, never_called)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::agent::{
AgentCapabilities, AgentDeclaration, AgentIdentity, CertificateSignature,
ToolCapability, CERTIFICATE_SCHEMA_VERSION, CERTIFICATE_TYPE,
};
use crate::session::manifest::{LifecycleMode, Participants, SessionStatus};
use crate::session::receipt::{SessionReceipt, SessionSection, ToolUsage, ToolUsageEntry};
use crate::session::render::RenderConfig;
use crate::session::side_effects::SideEffects;
fn certificate(ship_id: &str, tools: &[&str], issued: &str, valid_until: &str) -> AgentCertificate {
AgentCertificate {
r#type: CERTIFICATE_TYPE.into(),
schema_version: Some(CERTIFICATE_SCHEMA_VERSION.into()),
identity: AgentIdentity {
agent_name: "agent-007".into(),
ship_id: ship_id.into(),
public_key: "pk_b64".into(),
issuer: format!("ship://{ship_id}"),
issued_at: issued.into(),
valid_until: valid_until.into(),
model: None,
description: None,
},
capabilities: AgentCapabilities {
tools: tools
.iter()
.map(|n| ToolCapability { name: (*n).into(), description: None })
.collect(),
api_endpoints: vec![],
mcp_servers: vec![],
},
declaration: AgentDeclaration {
bounded_actions: tools.iter().map(|s| (*s).into()).collect(),
forbidden: vec![],
escalation_required: vec![],
},
signature: CertificateSignature {
algorithm: "ed25519".into(),
key_id: "key_1".into(),
public_key: "pk_b64".into(),
signature: "sig_b64".into(),
signed_fields: "identity+capabilities+declaration".into(),
},
}
}
fn receipt(ship_id: Option<&str>, tools_called: &[(&str, u32)]) -> SessionReceipt {
let tool_usage = if tools_called.is_empty() {
None
} else {
Some(ToolUsage {
declared: vec![],
actual: tools_called
.iter()
.map(|(n, c)| ToolUsageEntry { tool_name: (*n).into(), count: *c })
.collect(),
unauthorized: vec![],
})
};
SessionReceipt {
type_: crate::session::receipt::RECEIPT_TYPE.into(),
schema_version: Some(crate::session::receipt::RECEIPT_SCHEMA_VERSION.into()),
session: SessionSection {
id: "ssn_test".into(),
name: None,
mode: LifecycleMode::Manual,
started_at: "2026-04-10T00:00:00Z".into(),
ended_at: Some("2026-04-10T00:30:00Z".into()),
status: SessionStatus::Completed,
duration_ms: Some(1_800_000),
ship_id: ship_id.map(str::to_string),
narrative: None,
total_tokens_in: 0,
total_tokens_out: 0,
},
participants: Participants::default(),
hosts: vec![],
tools: vec![],
agent_graph: Default::default(),
timeline: vec![],
side_effects: SideEffects::default(),
artifacts: vec![],
proofs: Default::default(),
merkle: Default::default(),
render: RenderConfig {
title: None,
theme: None,
sections: RenderConfig::default_sections(),
generate_preview: true,
},
tool_usage,
}
}
const NOW: &str = "2026-04-18T10:00:00Z";
const ISSUED: &str = "2026-04-01T00:00:00Z";
const VALID_UNTIL: &str = "2027-04-01T00:00:00Z";
#[test]
fn all_tool_calls_authorized_passes() {
let cert = certificate("ship_a", &["Bash", "Read"], ISSUED, VALID_UNTIL);
let rec = receipt(Some("ship_a"), &[("Bash", 4), ("Read", 2)]);
let r = cross_verify_receipt_and_certificate(&rec, &cert, NOW);
assert_eq!(r.ship_id_status, ShipIdStatus::Match);
assert_eq!(r.certificate_status, CertificateStatus::Valid);
assert_eq!(r.authorized_tool_calls, vec!["Bash", "Read"]);
assert!(r.unauthorized_tool_calls.is_empty());
assert!(r.authorized_tools_never_called.is_empty());
assert!(r.ok());
}
#[test]
fn unauthorized_tool_call_flagged_and_blocks_ok() {
let cert = certificate("ship_a", &["Read"], ISSUED, VALID_UNTIL);
let rec = receipt(Some("ship_a"), &[("Read", 1), ("Write", 1)]);
let r = cross_verify_receipt_and_certificate(&rec, &cert, NOW);
assert_eq!(r.authorized_tool_calls, vec!["Read"]);
assert_eq!(r.unauthorized_tool_calls, vec!["Write"]);
assert!(r.authorized_tools_never_called.is_empty());
assert!(!r.ok(), "unauthorized call must block ok()");
}
#[test]
fn tools_authorized_but_never_called_reported_and_still_ok() {
let cert = certificate("ship_a", &["Bash", "Read", "DropDatabase"], ISSUED, VALID_UNTIL);
let rec = receipt(Some("ship_a"), &[("Bash", 1)]);
let r = cross_verify_receipt_and_certificate(&rec, &cert, NOW);
assert_eq!(r.authorized_tool_calls, vec!["Bash"]);
assert!(r.unauthorized_tool_calls.is_empty());
assert_eq!(
r.authorized_tools_never_called,
vec!["DropDatabase".to_string(), "Read".to_string()]
);
assert!(r.ok(), "unused authorization is not a failure");
}
#[test]
fn mismatched_ship_ids_blocks_ok() {
let cert = certificate("ship_a", &["Bash"], ISSUED, VALID_UNTIL);
let rec = receipt(Some("ship_b"), &[("Bash", 1)]);
let r = cross_verify_receipt_and_certificate(&rec, &cert, NOW);
assert_eq!(
r.ship_id_status,
ShipIdStatus::Mismatch {
receipt: "ship_b".into(),
certificate: "ship_a".into()
}
);
assert!(!r.ok());
}
#[test]
fn expired_certificate_blocks_ok() {
let cert = certificate("ship_a", &["Bash"], ISSUED, "2026-04-10T00:00:00Z");
let rec = receipt(Some("ship_a"), &[("Bash", 1)]);
let r = cross_verify_receipt_and_certificate(&rec, &cert, NOW);
assert_eq!(
r.certificate_status,
CertificateStatus::Expired {
valid_until: "2026-04-10T00:00:00Z".into(),
now: NOW.into()
}
);
assert!(!r.ok());
}
#[test]
fn not_yet_valid_certificate_blocks_ok() {
let cert = certificate("ship_a", &["Bash"], "2027-01-01T00:00:00Z", "2028-01-01T00:00:00Z");
let rec = receipt(Some("ship_a"), &[("Bash", 1)]);
let r = cross_verify_receipt_and_certificate(&rec, &cert, NOW);
assert!(matches!(
r.certificate_status,
CertificateStatus::NotYetValid { .. }
));
assert!(!r.ok());
}
#[test]
fn legacy_receipt_without_ship_id_is_unknown_and_blocks_ok() {
let cert = certificate("ship_a", &["Bash"], ISSUED, VALID_UNTIL);
let rec = receipt(None, &[("Bash", 1)]); let r = cross_verify_receipt_and_certificate(&rec, &cert, NOW);
assert_eq!(r.ship_id_status, ShipIdStatus::Unknown);
assert!(!r.ok(), "unknown ship_id must block ok() by default");
}
#[test]
fn no_tool_calls_in_receipt_yields_empty_lists() {
let cert = certificate("ship_a", &["Bash"], ISSUED, VALID_UNTIL);
let rec = receipt(Some("ship_a"), &[]);
let r = cross_verify_receipt_and_certificate(&rec, &cert, NOW);
assert!(r.authorized_tool_calls.is_empty());
assert!(r.unauthorized_tool_calls.is_empty());
assert_eq!(r.authorized_tools_never_called, vec!["Bash"]);
assert!(r.ok());
}
}