use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use crate::statements::ApprovalStatement;
use crate::verify::{signed_parent, SignedParent};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use super::receipt::{ArtifactEntry, SessionReceipt, RECEIPT_TYPE};
use crate::statements::{
approval_revocation_record_digest, approval_use_record_digest,
journal_checkpoint_record_digest, ApprovalRevocation, ApprovalUse, JournalCheckpoint,
ReplayCheck, ReplayCheckLevel,
};
#[derive(Debug)]
pub enum PackageError {
Io(std::io::Error),
Json(serde_json::Error),
InvalidPackage(String),
}
impl std::fmt::Display for PackageError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Io(e) => write!(f, "package io: {e}"),
Self::Json(e) => write!(f, "package json: {e}"),
Self::InvalidPackage(msg) => write!(f, "invalid package: {msg}"),
}
}
}
impl std::error::Error for PackageError {}
impl From<std::io::Error> for PackageError {
fn from(e: std::io::Error) -> Self {
Self::Io(e)
}
}
impl From<serde_json::Error> for PackageError {
fn from(e: serde_json::Error) -> Self {
Self::Json(e)
}
}
const RECEIPT_FILE: &str = "receipt.json";
const MERKLE_FILE: &str = "merkle.json";
const RENDER_FILE: &str = "render.json";
const ARTIFACTS_DIR: &str = "artifacts";
pub const ANCHORS_DIR: &str = "anchors";
const PROOFS_DIR: &str = "proofs";
const PREVIEW_FILE: &str = "preview.html";
const APPROVALS_DIR: &str = "approvals";
const APPROVALS_GRANTS: &str = "approvals/grants";
const APPROVALS_USES: &str = "approvals/uses";
const APPROVALS_CHECKPOINTS: &str = "approvals/checkpoints";
const APPROVALS_INDEX_FILE: &str = "approvals/index.json";
#[derive(Debug, Clone, Default)]
pub struct ApprovalsBundle {
pub grants: Vec<(String, Vec<u8>)>,
pub uses: Vec<ApprovalUse>,
pub checkpoints: Vec<JournalCheckpoint>,
pub revocations: Vec<ApprovalRevocation>,
pub action_envelopes: Vec<(String, Vec<u8>)>,
pub sealed_envelopes: Vec<(String, Vec<u8>)>,
pub signer_keys: Vec<(String, String)>,
pub sealed_anchors: Vec<(String, Vec<crate::storage::RecordAnchor>)>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct PackageKeys {
pub schema: String,
pub keys: std::collections::BTreeMap<String, String>,
}
pub const KEYS_FILE: &str = "keys.json";
pub const RECORD_FILE: &str = "record.json";
pub const OWN_KEY_LABEL: &str = "this ship's own key";
pub const PACKAGE_KEYS_SCHEMA: &str = "treeship/package-keys/v1";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApprovalsIndex {
#[serde(rename = "type")]
pub type_: String,
pub schema_version: u32,
pub grants: Vec<String>,
pub uses: Vec<String>,
pub checkpoints: Vec<String>,
pub revocations: Vec<String>,
}
impl ApprovalsIndex {
pub fn type_string() -> &'static str {
"treeship/approvals-index/v1"
}
}
pub struct PackageOutput {
pub path: PathBuf,
pub receipt_digest: String,
pub merkle_root: Option<String>,
pub file_count: usize,
}
pub fn build_package(
receipt: &SessionReceipt,
output_dir: &Path,
) -> Result<PackageOutput, PackageError> {
build_package_with_approvals(receipt, output_dir, None)
}
pub fn build_package_with_approvals(
receipt: &SessionReceipt,
output_dir: &Path,
bundle: Option<&ApprovalsBundle>,
) -> Result<PackageOutput, PackageError> {
let session_id = &receipt.session.id;
let pkg_dir = output_dir.join(format!("{session_id}.treeship"));
crate::fs_safe::create_dir_all_below(output_dir, &pkg_dir)?;
crate::fs_safe::create_dir_all_below(output_dir, &pkg_dir.join(ARTIFACTS_DIR))?;
crate::fs_safe::create_dir_all_below(output_dir, &pkg_dir.join(PROOFS_DIR))?;
let mut file_count = 0usize;
let receipt_bytes = serde_json::to_vec_pretty(receipt)?;
crate::fs_safe::write_atomic(&pkg_dir.join(RECEIPT_FILE), &receipt_bytes, 0o644)?;
file_count += 1;
let receipt_hash = Sha256::digest(&receipt_bytes);
let receipt_digest = format!("sha256:{}", hex::encode(receipt_hash));
let merkle_bytes = serde_json::to_vec_pretty(&receipt.merkle)?;
crate::fs_safe::write_atomic(&pkg_dir.join(MERKLE_FILE), &merkle_bytes, 0o644)?;
file_count += 1;
let render_bytes = serde_json::to_vec_pretty(&receipt.render)?;
crate::fs_safe::write_atomic(&pkg_dir.join(RENDER_FILE), &render_bytes, 0o644)?;
file_count += 1;
for proof_entry in &receipt.merkle.inclusion_proofs {
let proof_bytes = serde_json::to_vec_pretty(proof_entry)?;
let filename = format!("{}.proof.json", proof_entry.artifact_id);
crate::fs_safe::write_atomic(
&pkg_dir.join(PROOFS_DIR).join(filename),
&proof_bytes,
0o644,
)?;
file_count += 1;
}
if receipt.render.generate_preview {
let preview = render_preview_html_with_approvals(receipt, bundle);
crate::fs_safe::write_atomic(&pkg_dir.join(PREVIEW_FILE), preview.as_bytes(), 0o644)?;
file_count += 1;
}
if let Some(b) = bundle {
if !b.sealed_envelopes.is_empty() {
crate::fs_safe::create_dir_all_below(output_dir, &pkg_dir.join(ARTIFACTS_DIR))?;
for (artifact_id, envelope_bytes) in &b.sealed_envelopes {
let safe = sanitize_filename(artifact_id);
let path = pkg_dir.join(ARTIFACTS_DIR).join(format!("{safe}.json"));
if !path.exists() {
crate::fs_safe::write_atomic(&path, envelope_bytes, 0o644)?;
file_count += 1;
}
}
}
let proofs: Vec<_> = b
.sealed_anchors
.iter()
.map(|(id, anchors)| {
let with_proof: Vec<_> = anchors
.iter()
.filter(|a| a.proof.is_some())
.cloned()
.collect();
(id, with_proof)
})
.filter(|(_, a)| !a.is_empty())
.collect();
if !proofs.is_empty() {
crate::fs_safe::create_dir_all_below(output_dir, &pkg_dir.join(ANCHORS_DIR))?;
for (artifact_id, anchors) in proofs {
let safe = sanitize_filename(artifact_id);
std::fs::write(
pkg_dir.join(ANCHORS_DIR).join(format!("{safe}.json")),
serde_json::to_vec_pretty(&anchors)?,
)?;
file_count += 1;
}
}
if !b.signer_keys.is_empty() {
let keys = PackageKeys {
schema: PACKAGE_KEYS_SCHEMA.into(),
keys: b.signer_keys.iter().cloned().collect(),
};
crate::fs_safe::write_atomic(
&pkg_dir.join(KEYS_FILE),
&serde_json::to_vec_pretty(&keys)?,
0o644,
)?;
file_count += 1;
}
if !b.grants.is_empty()
|| !b.uses.is_empty()
|| !b.checkpoints.is_empty()
|| !b.revocations.is_empty()
|| !b.action_envelopes.is_empty()
{
crate::fs_safe::create_dir_all_below(output_dir, &pkg_dir.join(APPROVALS_GRANTS))?;
crate::fs_safe::create_dir_all_below(output_dir, &pkg_dir.join(APPROVALS_USES))?;
crate::fs_safe::create_dir_all_below(output_dir, &pkg_dir.join(APPROVALS_CHECKPOINTS))?;
crate::fs_safe::create_dir_all_below(output_dir, &pkg_dir.join(ARTIFACTS_DIR))?;
for (artifact_id, envelope_bytes) in &b.action_envelopes {
let safe = sanitize_filename(artifact_id);
std::fs::write(
pkg_dir.join(ARTIFACTS_DIR).join(format!("{safe}.json")),
envelope_bytes,
)?;
file_count += 1;
}
let mut grant_ids = Vec::with_capacity(b.grants.len());
for (grant_id, envelope_bytes) in &b.grants {
let safe = sanitize_filename(grant_id);
std::fs::write(
pkg_dir.join(APPROVALS_GRANTS).join(format!("{safe}.json")),
envelope_bytes,
)?;
grant_ids.push(grant_id.clone());
file_count += 1;
}
let mut use_ids = Vec::with_capacity(b.uses.len());
for u in &b.uses {
let safe = sanitize_filename(&u.use_id);
let bytes = serde_json::to_vec_pretty(u)?;
std::fs::write(
pkg_dir.join(APPROVALS_USES).join(format!("{safe}.json")),
&bytes,
)?;
use_ids.push(u.use_id.clone());
file_count += 1;
}
let mut checkpoint_ids = Vec::with_capacity(b.checkpoints.len());
for cp in &b.checkpoints {
let safe = sanitize_filename(&cp.checkpoint_id);
let bytes = serde_json::to_vec_pretty(cp)?;
std::fs::write(
pkg_dir
.join(APPROVALS_CHECKPOINTS)
.join(format!("{safe}.json")),
&bytes,
)?;
checkpoint_ids.push(cp.checkpoint_id.clone());
file_count += 1;
}
let mut revocation_ids = Vec::with_capacity(b.revocations.len());
for rev in &b.revocations {
let safe = sanitize_filename(&rev.revocation_id);
let bytes = serde_json::to_vec_pretty(rev)?;
std::fs::write(
pkg_dir
.join(APPROVALS_DIR)
.join(format!("revocations-{safe}.json")),
&bytes,
)?;
revocation_ids.push(rev.revocation_id.clone());
file_count += 1;
}
let index = ApprovalsIndex {
type_: ApprovalsIndex::type_string().into(),
schema_version: 1,
grants: grant_ids,
uses: use_ids,
checkpoints: checkpoint_ids,
revocations: revocation_ids,
};
let index_bytes = serde_json::to_vec_pretty(&index)?;
crate::fs_safe::write_atomic(&pkg_dir.join(APPROVALS_INDEX_FILE), &index_bytes, 0o644)?;
file_count += 1;
}
}
Ok(PackageOutput {
path: pkg_dir,
receipt_digest,
merkle_root: receipt.merkle.root.clone(),
file_count,
})
}
fn sanitize_filename(s: &str) -> String {
s.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' || c == '.' || c == '_' {
c
} else {
'_'
}
})
.collect()
}
pub fn read_approvals_bundle(pkg_dir: &Path) -> Result<ApprovalsBundle, PackageError> {
let approvals_dir = pkg_dir.join(APPROVALS_DIR);
if !approvals_dir.is_dir() {
return Ok(ApprovalsBundle::default());
}
let mut bundle = ApprovalsBundle::default();
let grants_dir = pkg_dir.join(APPROVALS_GRANTS);
if grants_dir.is_dir() {
for entry in std::fs::read_dir(&grants_dir)? {
let entry = entry?;
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) != Some("json") {
continue;
}
let id = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_string();
let bytes = std::fs::read(&path)?;
bundle.grants.push((id, bytes));
}
}
let uses_dir = pkg_dir.join(APPROVALS_USES);
if uses_dir.is_dir() {
for entry in std::fs::read_dir(&uses_dir)? {
let entry = entry?;
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) != Some("json") {
continue;
}
let bytes = std::fs::read(&path)?;
let u: ApprovalUse = serde_json::from_slice(&bytes)?;
bundle.uses.push(u);
}
}
let cps_dir = pkg_dir.join(APPROVALS_CHECKPOINTS);
if cps_dir.is_dir() {
for entry in std::fs::read_dir(&cps_dir)? {
let entry = entry?;
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) != Some("json") {
continue;
}
let bytes = std::fs::read(&path)?;
let cp: JournalCheckpoint = serde_json::from_slice(&bytes)?;
bundle.checkpoints.push(cp);
}
}
let arts_dir = pkg_dir.join(ARTIFACTS_DIR);
if arts_dir.is_dir() {
for entry in std::fs::read_dir(&arts_dir)? {
let entry = entry?;
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) != Some("json") {
continue;
}
let id = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_string();
let bytes = std::fs::read(&path)?;
bundle.action_envelopes.push((id, bytes));
}
}
Ok(bundle)
}
pub fn read_package(pkg_dir: &Path) -> Result<SessionReceipt, PackageError> {
let receipt_path = pkg_dir.join(RECEIPT_FILE);
if !receipt_path.exists() {
return Err(PackageError::InvalidPackage(format!(
"missing {RECEIPT_FILE} in {}",
pkg_dir.display()
)));
}
let bytes = std::fs::read(&receipt_path)?;
let receipt: SessionReceipt = serde_json::from_slice(&bytes)?;
if receipt.type_ != RECEIPT_TYPE {
return Err(PackageError::InvalidPackage(format!(
"unexpected type: {} (expected {RECEIPT_TYPE})",
receipt.type_
)));
}
Ok(receipt)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PackageVerdict {
Verified,
SignaturesPass,
StructuralPass,
Failed(String),
}
impl PackageVerdict {
pub fn as_str(&self) -> &'static str {
match self {
Self::Verified => "verified",
Self::SignaturesPass => "signatures-pass",
Self::StructuralPass => "structural-pass",
Self::Failed(_) => "failed",
}
}
}
pub fn package_verdict(checks: &[VerifyCheck], structural_only: bool) -> PackageVerdict {
let failed: Vec<&str> = checks
.iter()
.filter(|c| c.status == VerifyStatus::Fail)
.map(|c| c.name.as_str())
.collect();
if !failed.is_empty() {
return PackageVerdict::Failed(format!("failed: {}", failed.join(", ")));
}
let status = |name: &str| {
checks
.iter()
.find(|c| c.name == name)
.map(|c| c.status.clone())
};
if structural_only {
if status("merkle_root") != Some(VerifyStatus::Pass) {
return PackageVerdict::Failed("not verified: the package seals no artifacts".into());
}
return PackageVerdict::StructuralPass;
}
let mut missing = Vec::new();
if !checks
.iter()
.any(|c| c.name.starts_with("signature:") && c.status == VerifyStatus::Pass)
{
missing.push("no artifact signature verified");
}
if status("receipt_binding") != Some(VerifyStatus::Pass) {
missing.push("the close record does not bind receipt.json");
}
let trust = status("signer_trust");
if trust.is_none() {
missing.push("no signing key was judged");
}
if !missing.is_empty() {
return PackageVerdict::Failed(format!("not verified: {}", missing.join("; ")));
}
let approver_ok = status("approval_signer").is_none_or(|s| s == VerifyStatus::Pass);
match trust {
Some(VerifyStatus::Pass) if approver_ok => PackageVerdict::Verified,
_ => PackageVerdict::SignaturesPass,
}
}
pub fn package_close_signed_by(pkg_dir: &Path, keys: &[ed25519_dalek::VerifyingKey]) -> bool {
let Some(env) = std::fs::read(pkg_dir.join(RECORD_FILE))
.ok()
.and_then(|raw| crate::attestation::Envelope::from_json(&raw).ok())
else {
return false;
};
if env.payload_type != crate::statements::payload_type("receipt") {
return false;
}
env.signatures.iter().any(|sig| {
keys.iter()
.any(|vk| crate::attestation::verify_with_key(&env, &sig.keyid, *vk).is_ok())
})
}
pub fn verify_package(pkg_dir: &Path) -> Result<Vec<VerifyCheck>, PackageError> {
let trust = match crate::trust::TrustRootStore::open_default_or_empty() {
Ok(t) => t,
Err(e) => {
return Ok(vec![VerifyCheck::fail(
"trust-root",
&format!("trust store unreadable: {e}"),
)]);
}
};
verify_package_with_trust(pkg_dir, &trust)
}
pub fn verify_package_with_trust(
pkg_dir: &Path,
trust: &crate::trust::TrustRootStore,
) -> Result<Vec<VerifyCheck>, PackageError> {
verify_package_with_options(pkg_dir, trust, false)
}
pub fn verify_package_structural(pkg_dir: &Path) -> Result<Vec<VerifyCheck>, PackageError> {
let trust = crate::trust::TrustRootStore::open_default_or_empty()
.unwrap_or_else(|_| crate::trust::TrustRootStore::empty());
verify_package_with_options(pkg_dir, &trust, true)
}
pub fn verify_package_with_options(
pkg_dir: &Path,
trust: &crate::trust::TrustRootStore,
structural_only: bool,
) -> Result<Vec<VerifyCheck>, PackageError> {
let mut checks = Vec::new();
let receipt = match read_package(pkg_dir) {
Ok(r) => {
checks.push(VerifyCheck::pass(
"receipt.json",
"Parses as valid Session Receipt",
));
r
}
Err(e) => {
checks.push(VerifyCheck::fail(
"receipt.json",
&format!("Failed to parse: {e}"),
));
return Ok(checks);
}
};
if receipt.type_ == RECEIPT_TYPE {
checks.push(VerifyCheck::pass("type", "Correct receipt type"));
} else {
checks.push(VerifyCheck::fail(
"type",
&format!("Expected {RECEIPT_TYPE}, got {}", receipt.type_),
));
}
let receipt_path = pkg_dir.join(RECEIPT_FILE);
let on_disk = std::fs::read(&receipt_path)?;
let re_serialized = serde_json::to_vec_pretty(&receipt)?;
if on_disk == re_serialized {
checks.push(VerifyCheck::pass(
"determinism",
"receipt.json round-trips identically (structural, NOT a signature)",
));
} else {
checks.push(VerifyCheck::warn(
"determinism",
"receipt.json does not byte-match after re-serialization",
));
}
checks.push(coverage_check(pkg_dir, &receipt));
if let Some(tu) = receipt.tool_usage.as_ref() {
if !tu.network_declared.is_empty() {
let total = receipt.side_effects.network_connections.len();
if tu.network_off_scope.is_empty() {
checks.push(VerifyCheck::pass(
"network_scope",
&format!(
"{total} recorded connection(s), all within the declared scope [{}]",
tu.network_declared.join(", ")
),
));
} else {
checks.push(VerifyCheck::warn(
"network_scope",
&format!(
"{} destination(s) outside the declared scope [{}]: {}",
tu.network_off_scope.len(),
tu.network_declared.join(", "),
tu.network_off_scope.join(", ")
),
));
}
}
}
if let Some(row) = retries_check(pkg_dir, &receipt) {
checks.push(row);
}
if let Some(row) = judgements_check(pkg_dir, &receipt) {
checks.push(row);
}
if !receipt.artifacts.is_empty() {
let version = receipt.merkle.merkle_version;
let mut tree = match crate::merkle::MerkleTree::with_version(version) {
Ok(t) => t,
Err(e) => {
checks.push(VerifyCheck::fail(
"merkle_root",
&format!("receipt declared unknown merkle_version: {e}"),
));
return Ok(finish_package_checks(checks, &receipt));
}
};
for art in &receipt.artifacts {
tree.append(&art.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!(
"Mismatch: on-disk {:?} vs recomputed {:?}",
receipt.merkle.root, recomputed_root
),
));
}
for proof_entry in &receipt.merkle.inclusion_proofs {
if proof_entry.proof.merkle_version != version {
checks.push(VerifyCheck::fail(
&format!("inclusion:{}", proof_entry.artifact_id),
&format!(
"proof merkle_version {} != receipt section v{}",
proof_entry.proof.merkle_version, version,
),
));
continue;
}
let verified = crate::merkle::MerkleTree::verify_proof(
version,
&root_hex,
&proof_entry.artifact_id,
&proof_entry.proof,
);
if verified {
checks.push(VerifyCheck::pass(
&format!("inclusion:{}", proof_entry.artifact_id),
"Inclusion proof valid",
));
} else {
checks.push(VerifyCheck::fail(
&format!("inclusion:{}", proof_entry.artifact_id),
"Inclusion proof failed verification",
));
}
}
} else {
checks.push(VerifyCheck::warn("merkle_root", "No artifacts to verify"));
}
let sealed = verify_sealed_envelopes(pkg_dir, &receipt, structural_only, &mut checks);
verify_stapled_anchors(pkg_dir, &receipt, trust, &mut checks);
let (body_bound, vouched) =
verify_receipt_binding(pkg_dir, &receipt, structural_only, &sealed, &mut checks);
let authenticated = push_signer_trust(
pkg_dir,
&sealed.signers,
vouched.as_ref(),
trust,
&mut checks,
);
let mut approvers = sealed.approval_signers.clone();
approvers.extend(
bundle_grants(pkg_dir, &read_approvals_bundle(pkg_dir).unwrap_or_default())
.0
.into_iter()
.map(|g| g.keyid),
);
push_chain_completeness(&receipt, &sealed, &authenticated, &mut checks);
push_approval_signer(pkg_dir, &approvers, trust, &mut checks);
push_key_id_collisions(pkg_dir, trust, &mut checks);
push_approval_evidence(pkg_dir, &receipt, structural_only, &mut checks);
if body_bound {
checks.push(VerifyCheck::pass(
"receipt_body_binding",
"timeline/side-effects/narrative are bound: the close record (record.json) signs the digest of the whole receipt.json, so editing any of them fails receipt_binding. They remain the producer's own account of the session, composed from its event log and signed by its key; the artifacts are the evidence",
));
} else {
checks.push(VerifyCheck::warn(
"receipt_body_binding",
"timeline/side-effects/narrative are NOT signed in this package — only the artifacts and Merkle root are cryptographically bound. For an authenticated record of the session, verify the actor-signed session.v1 record (or the published report).",
));
}
verify_session_window(pkg_dir, &receipt, &mut checks);
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",
));
}
if receipt.proofs.event_log_skipped > 0 {
checks.push(VerifyCheck::warn(
"event_log_completeness",
&format!(
"{} event(s) skipped during close (malformed lines in events.jsonl). \
Receipt is cryptographically valid but does not represent the full event stream. \
Inspect close-time stderr or the events.jsonl directly to investigate.",
receipt.proofs.event_log_skipped,
),
));
}
if receipt.proofs.reconcile_untracked_truncated > 0 {
checks.push(VerifyCheck::warn(
"reconcile_completeness",
&format!(
"untracked git reconcile exceeded cap {} (saw at least {}). \
Per-file synthetic events were skipped and the receipt is bounded, not complete for untracked files.",
receipt.proofs.reconcile_untracked_cap,
receipt.proofs.reconcile_untracked_truncated,
),
));
}
if receipt.proofs.reconcile_degraded {
checks.push(VerifyCheck::warn(
"reconcile_degraded",
"the git reconcile backstop was UNAVAILABLE at session close although git worked at start \
(.git removed, corrupt index, or git not on PATH). Files changed outside a captured \
AgentWroteFile event may be MISSING from this receipt's file ledger — treat the \
\"Files changed\" list as incomplete.",
));
}
let bundle = read_approvals_bundle(pkg_dir).unwrap_or_default();
add_approval_evidence_checks(&mut checks, &bundle, trust);
push_approval_use_limit(pkg_dir, &receipt, &bundle, structural_only, &mut checks);
Ok(checks)
}
struct BundleGrant {
id: String,
keyid: String,
nonce_digest: Option<String>,
max: Option<u32>,
}
fn bundle_grants(pkg_dir: &Path, bundle: &ApprovalsBundle) -> (Vec<BundleGrant>, Vec<String>) {
let keys = package_verifying_keys(pkg_dir);
let mut ok = Vec::new();
let mut bad = Vec::new();
for (grant_id, raw) in &bundle.grants {
let env = match crate::attestation::Envelope::from_json(raw) {
Ok(e) => e,
Err(e) => {
bad.push(format!("{grant_id}: envelope does not parse ({e})"));
continue;
}
};
let Some(sig) = env.signatures.first() else {
bad.push(format!("{grant_id}: no signature"));
continue;
};
let Some(vk) = keys.get(&sig.keyid) else {
bad.push(format!(
"{grant_id}: signed by {}, a key the package does not carry",
sig.keyid
));
continue;
};
match crate::attestation::verify_with_key(&env, &sig.keyid, *vk) {
Ok(res) if res.artifact_id == *grant_id => {}
Ok(res) => {
bad.push(format!(
"{grant_id}: the signed bytes re-derive to {}",
res.artifact_id
));
continue;
}
Err(e) => {
bad.push(format!("{grant_id}: invalid signature ({e})"));
continue;
}
}
let v = env
.payload_bytes()
.ok()
.and_then(|b| serde_json::from_slice::<serde_json::Value>(&b).ok());
let max = match signed_max_actions(v.as_ref()) {
Ok(m) => m,
Err(detail) => {
bad.push(format!("{grant_id}: {detail}"));
continue;
}
};
ok.push(BundleGrant {
id: grant_id.clone(),
keyid: sig.keyid.clone(),
nonce_digest: v
.as_ref()
.and_then(|v| v.get("nonce"))
.and_then(|n| n.as_str())
.map(crate::statements::nonce_digest),
max,
});
}
(ok, bad)
}
fn signed_max_actions(payload: Option<&serde_json::Value>) -> Result<Option<u32>, String> {
let Some(m) = payload
.and_then(|v| v.get("scope"))
.and_then(|s| s.get("maxActions"))
else {
return Ok(None);
};
let Some(n) = m.as_u64() else {
return Err(format!("scope.maxActions is {m}, not a whole number"));
};
u32::try_from(n)
.map(Some)
.map_err(|_| format!("scope.maxActions {n} is beyond the supported range"))
}
fn push_approval_use_limit(
pkg_dir: &Path,
receipt: &SessionReceipt,
bundle: &ApprovalsBundle,
structural_only: bool,
checks: &mut Vec<VerifyCheck>,
) {
use std::collections::{BTreeMap, BTreeSet};
let verified = |id: &str| {
checks
.iter()
.any(|c| c.name == format!("signature:{id}") && c.status == VerifyStatus::Pass)
};
let mut grants: BTreeMap<String, (String, Option<u32>)> = BTreeMap::new();
let mut consumers: BTreeMap<String, Vec<String>> = BTreeMap::new();
let mut shared: Vec<String> = Vec::new();
let mut bad_scopes: Vec<String> = Vec::new();
for a in &receipt.artifacts {
let Some(env) = std::fs::read(
pkg_dir
.join(ARTIFACTS_DIR)
.join(format!("{}.json", sanitize_filename(&a.artifact_id))),
)
.ok()
.and_then(|raw| crate::attestation::Envelope::from_json(&raw).ok()) else {
continue;
};
let Some(v) = env
.payload_bytes()
.ok()
.and_then(|b| serde_json::from_slice::<serde_json::Value>(&b).ok())
else {
continue;
};
if env.payload_type == crate::statements::payload_type("approval") {
if !verified(&a.artifact_id) {
continue;
}
if let Some(n) = v.get("nonce").and_then(|n| n.as_str()) {
let max = match signed_max_actions(Some(&v)) {
Ok(m) => m,
Err(detail) => {
bad_scopes.push(format!("approval {}: {detail}", a.artifact_id));
continue;
}
};
let nd = crate::statements::nonce_digest(n);
if let Some((other, _)) = grants.get(&nd) {
if other != &a.artifact_id {
shared.push(format!("{other} and {}", a.artifact_id));
}
}
grants.insert(nd, (a.artifact_id.clone(), max));
}
} else if let Some(n) = v.get("approvalNonce").and_then(|n| n.as_str()) {
consumers
.entry(crate::statements::nonce_digest(n))
.or_default()
.push(a.artifact_id.clone());
}
}
let (carried, bad_grants) = bundle_grants(pkg_dir, bundle);
for g in carried {
let Some(nd) = g.nonce_digest else { continue };
if let Some((other, _)) = grants.get(&nd) {
if other != &g.id {
shared.push(format!("{other} and {}", g.id));
}
continue;
}
grants.insert(nd, (g.id, g.max));
}
if consumers.is_empty()
&& bundle.uses.is_empty()
&& bad_grants.is_empty()
&& shared.is_empty()
&& bad_scopes.is_empty()
{
return;
}
let mut problems = Vec::new();
for pair in &shared {
problems.push(format!("approvals {pair} share one nonce"));
}
for b in &bad_grants {
problems.push(format!("carried grant {b}"));
}
problems.extend(bad_scopes);
let mut unsigned = Vec::new();
let mut unbounded: Vec<String> = Vec::new();
let nonces: BTreeSet<&String> = consumers
.keys()
.chain(bundle.uses.iter().map(|u| &u.nonce_digest))
.collect();
for nd in nonces {
let acts = consumers.get(nd).map(Vec::len).unwrap_or(0);
let use_ids: BTreeSet<&str> = bundle
.uses
.iter()
.filter(|u| &u.nonce_digest == nd)
.map(|u| u.use_id.as_str())
.collect();
let uses = bundle.uses.iter().filter(|u| &u.nonce_digest == nd).count();
if let Some((_, Some(signed))) = grants.get(nd) {
let disagreeing: Vec<String> = bundle
.uses
.iter()
.filter(|u| &u.nonce_digest == nd && u.max_uses != Some(*signed))
.map(|u| {
format!(
"use {} records max_uses {} but the signed scope says {signed}",
u.use_id,
u.max_uses
.map(|m| m.to_string())
.unwrap_or_else(|| "none".into()),
)
})
.collect();
problems.extend(disagreeing);
}
match grants.get(nd) {
Some((grant, Some(max))) => {
if acts as u32 > *max {
problems.push(format!(
"approval {grant} is signed for {max} use(s) but {acts} sealed action(s) consume it"
));
}
if uses as u32 > *max {
problems.push(format!(
"approval {grant} is signed for {max} use(s) but the package records {uses} use(s)"
));
}
}
Some((grant, None)) => {
if acts > 0 || uses > 0 {
unbounded.push(grant.clone());
}
}
None if acts > 0 => unsigned.push(consumers[nd].join(", ")),
None => {}
}
if acts > use_ids.len() && !structural_only {
problems.push(format!(
"{acts} sealed action(s) consume one approval but only {} distinct use record(s) cover them",
use_ids.len()
));
}
}
if !problems.is_empty() {
checks.push(VerifyCheck::fail(
"approval-use-limit",
&problems.join("; "),
));
} else if !unsigned.is_empty() {
checks.push(VerifyCheck::warn(
"approval-use-limit",
&format!(
"action(s) {} consume an approval that is not sealed (signed) in this package, so its use limit cannot be checked here",
unsigned.join("; ")
),
));
} else if !unbounded.is_empty() {
checks.push(VerifyCheck::pass(
"approval-use-limit",
&format!(
"approval(s) {} are unbounded (no maxActions in the signed scope), so no use limit applies to them; every other approval is used within the limit its signed scope sets, each consuming action with its own use record",
unbounded.join(", ")
),
));
} else {
checks.push(VerifyCheck::pass(
"approval-use-limit",
"every approval is used within the limit its signed scope sets, each consuming action with its own use record",
));
}
}
const SIGNER_KINDS: &[crate::trust::TrustRootKind] = &[
crate::trust::TrustRootKind::CertIssuer,
crate::trust::TrustRootKind::AgentCert,
crate::trust::TrustRootKind::SessionHost,
];
fn read_package_keys(pkg_dir: &Path) -> Option<PackageKeys> {
let raw = std::fs::read(pkg_dir.join(KEYS_FILE)).ok()?;
serde_json::from_slice(&raw).ok()
}
fn package_verifying_keys(
pkg_dir: &Path,
) -> std::collections::BTreeMap<String, ed25519_dalek::VerifyingKey> {
let mut keys = std::collections::BTreeMap::new();
if let Some(pk) = read_package_keys(pkg_dir) {
for (id, encoded) in pk.keys {
if let Ok(vk) = crate::trust::decode_ed25519_pubkey(&encoded) {
keys.insert(id, vk);
}
}
}
keys
}
fn verify_receipt_binding(
pkg_dir: &Path,
receipt: &SessionReceipt,
structural_only: bool,
sealed: &SealedSet,
checks: &mut Vec<VerifyCheck>,
) -> (bool, Option<Vouched>) {
use sha2::{Digest, Sha256};
let fail = |checks: &mut Vec<VerifyCheck>, detail: String| {
checks.push(VerifyCheck::fail("receipt_binding", &detail));
(false, None)
};
let path = pkg_dir.join(RECORD_FILE);
let raw = match std::fs::read(&path) {
Ok(b) => b,
Err(_) => {
let detail = "the package carries no close record, so the sealed set is not under a signature: an artifact could be added to the list, or the receipt edited, and the tree recomputed without any per-artifact row failing. Either it was built before 0.31.4, or record.json was removed; the bytes cannot say which";
if structural_only {
checks.push(VerifyCheck::warn("receipt_binding", detail));
return (false, None);
}
return fail(checks, format!("{detail}. For a package you know was built before 0.31.4, --structural reports what can still be checked (verdict structural-pass)"));
}
};
if structural_only {
checks.push(VerifyCheck::warn(
"receipt_binding",
"close record present but not checked under --structural",
));
return (false, None);
}
let envelope = match crate::attestation::Envelope::from_json(&raw) {
Ok(e) => e,
Err(e) => {
return fail(
checks,
format!("record.json does not parse as a DSSE envelope: {e}"),
)
}
};
let record_pt = crate::statements::payload_type("receipt");
if envelope.payload_type != record_pt {
return fail(
checks,
format!(
"record.json is a {} envelope, not a close record ({record_pt})",
envelope.payload_type
),
);
}
let Some(sig) = envelope.signatures.first() else {
return fail(checks, "record.json carries no signature".into());
};
let keys = package_verifying_keys(pkg_dir);
let Some(vk) = keys.get(&sig.keyid) else {
return fail(
checks,
format!(
"record.json is signed by {}, a key the package does not carry",
sig.keyid
),
);
};
if let Err(e) = crate::attestation::verify_with_key(&envelope, &sig.keyid, *vk) {
return fail(
checks,
format!("record.json signature invalid for key {}: {e}", sig.keyid),
);
}
let statement: serde_json::Value = match envelope
.payload_bytes()
.ok()
.and_then(|b| serde_json::from_slice(&b).ok())
{
Some(v) => v,
None => return fail(checks, "record.json payload is not JSON".into()),
};
let s_type = statement.get("type").and_then(|t| t.as_str());
let s_kind = statement.get("kind").and_then(|k| k.as_str());
if s_type != Some(crate::statements::TYPE_RECEIPT) || s_kind != Some("session.v1") {
return fail(
checks,
format!(
"record.json is a {} statement of kind {}, not a session.v1 close record",
s_type.unwrap_or("(untyped)"),
s_kind.unwrap_or("(none)")
),
);
}
let body = statement.get("payload");
let (Some(signed_digest), Some(signed_session)) = (
body.and_then(|p| p.get("receipt_digest"))
.and_then(|d| d.as_str()),
body.and_then(|p| p.get("session_id"))
.and_then(|d| d.as_str()),
) else {
return fail(
checks,
"the close record does not name a receipt_digest and a session_id".into(),
);
};
let receipt_bytes = match std::fs::read(pkg_dir.join(RECEIPT_FILE)) {
Ok(b) => b,
Err(e) => return fail(checks, format!("receipt.json unreadable: {e}")),
};
let actual = format!("sha256:{}", hex::encode(Sha256::digest(&receipt_bytes)));
if signed_session != receipt.session.id {
return fail(
checks,
format!(
"the close record names session {} but this receipt is {}",
signed_session, receipt.session.id
),
);
}
if signed_digest != actual {
return fail(
checks,
format!(
"the producer signed receipt digest {} at close but receipt.json now digests to {}: the sealed set was rewritten after it was signed",
signed_digest, actual
),
);
}
let Some(subject) = statement
.get("subject")
.and_then(|s| s.get("artifactId"))
.and_then(|a| a.as_str())
else {
return fail(
checks,
"the close record names no subject: it must name the sealed session.close action"
.into(),
);
};
let session_id_of = |c: &SealedClose| {
c.statement
.get("meta")
.and_then(|m| m.get("session_id"))
.and_then(|v| v.as_str())
.map(str::to_string)
};
let session_closes: Vec<&SealedClose> = sealed
.closes
.iter()
.filter(|c| session_id_of(c).as_deref() == Some(receipt.session.id.as_str()))
.collect();
if session_closes.len() > 1 {
return fail(
checks,
format!(
"the package seals {} session.close actions for session {}: {}",
session_closes.len(),
receipt.session.id,
session_closes
.iter()
.map(|c| c.id.as_str())
.collect::<Vec<_>>()
.join(", ")
),
);
}
let Some(close) = sealed.closes.iter().find(|c| c.id == subject) else {
return fail(
checks,
format!("the close record names {subject}, which is not a sealed, verified session.close action in this package"),
);
};
let meta = close.statement.get("meta");
if meta
.and_then(|m| m.get("session_id"))
.and_then(|v| v.as_str())
!= Some(receipt.session.id.as_str())
{
return fail(
checks,
format!(
"the session.close {subject} the record names does not close session {}",
receipt.session.id
),
);
}
if !close.chained {
return fail(
checks,
format!("session.close {subject} is sealed unchained; the close must be on the session's chain"),
);
}
let root_id = receipt
.artifacts
.iter()
.find(|a| !a.unchained)
.map(|a| a.artifact_id.as_str());
let root = root_id
.and_then(|r| sealed.starts.iter().find(|s| s.id == r))
.filter(|s| session_id_of(s).as_deref() == Some(receipt.session.id.as_str()));
let Some(root) = root else {
return fail(
checks,
format!(
"the chain's first artifact ({}) is not a verified session.start for session {}",
root_id.unwrap_or("none"),
receipt.session.id
),
);
};
if root.keyid != close.keyid {
return fail(
checks,
format!(
"session.close {subject} is signed by {}, but the session.start that roots the chain ({}) is signed by {}: the close is not the producer's",
close.keyid, root.id, root.keyid
),
);
}
let vouched = if sig.keyid == close.keyid {
None
} else {
let named = meta.and_then(|m| m.get("record_key"));
let named_id = named.and_then(|k| k.get("key_id")).and_then(|v| v.as_str());
let named_pub = named
.and_then(|k| k.get("public_key"))
.and_then(|v| v.as_str())
.and_then(|p| crate::trust::decode_ed25519_pubkey(p).ok());
if named_id != Some(sig.keyid.as_str()) || named_pub.as_ref() != Some(vk) {
return fail(
checks,
format!(
"record.json is signed by {}, which is neither the signer of session.close {subject} ({}) nor the record key that signed session.close names. A package closed before 0.31.10 by an agent with its own key names none; read it with --structural",
sig.keyid, close.keyid
),
);
}
Some(Vouched {
key: sig.keyid.clone(),
by: close.keyid.clone(),
})
};
let how = match &vouched {
None => format!("the signer of session.close {subject}"),
Some(v) => format!(
"the record key session.close {subject} names, signed by {}",
v.by
),
};
checks.push(VerifyCheck::pass(
"receipt_binding",
&format!(
"close record signed by {} (fp {}; {how}) binds receipt.json ({}) and names this session",
sig.keyid,
key_fingerprint(vk),
actual
),
));
(true, vouched)
}
struct Vouched {
key: String,
by: String,
}
fn verify_session_window(pkg_dir: &Path, receipt: &SessionReceipt, checks: &mut Vec<VerifyCheck>) {
use crate::statements::invitation::parse_rfc3339_to_unix;
const SKEW: u64 = 120;
let Some(started) = parse_rfc3339_to_unix(&receipt.session.started_at) else {
return;
};
let ended = receipt
.session
.ended_at
.as_deref()
.and_then(parse_rfc3339_to_unix);
let art_dir = pkg_dir.join(ARTIFACTS_DIR);
let mut outside: Vec<String> = Vec::new();
let mut seen = 0usize;
for entry in &receipt.artifacts {
let path = art_dir.join(format!("{}.json", sanitize_filename(&entry.artifact_id)));
let Ok(raw) = std::fs::read(&path) else {
continue;
};
let Ok(env) = crate::attestation::Envelope::from_json(&raw) else {
continue;
};
let Some(ts) = env
.payload_bytes()
.ok()
.and_then(|b| serde_json::from_slice::<serde_json::Value>(&b).ok())
.and_then(|v| {
v.get("timestamp")
.and_then(|t| t.as_str())
.map(str::to_string)
})
else {
continue;
};
let Some(t) = parse_rfc3339_to_unix(&ts) else {
continue;
};
seen += 1;
let before = t + SKEW < started;
let after = ended.map(|e| t > e + SKEW).unwrap_or(false);
if before || after {
outside.push(format!("{} ({})", entry.artifact_id, ts));
}
}
if seen == 0 {
return;
}
if outside.is_empty() {
checks.push(VerifyCheck::pass(
"session_window",
&format!("{seen} sealed artifact(s) were signed inside the session's window"),
));
} else {
checks.push(VerifyCheck::warn(
"session_window",
&format!(
"{} sealed artifact(s) were signed outside the session's window ({} to {}): {}",
outside.len(),
receipt.session.started_at,
receipt
.session
.ended_at
.clone()
.unwrap_or_else(|| "open".into()),
outside.join(", ")
),
));
}
}
fn verify_stapled_anchors(
pkg_dir: &Path,
receipt: &SessionReceipt,
trust: &crate::trust::TrustRootStore,
checks: &mut Vec<VerifyCheck>,
) {
use crate::verify::rekor::{verify_rekor_entry, RekorVerifyError};
let dir = pkg_dir.join(ANCHORS_DIR);
if !dir.is_dir() {
return;
}
let logs = crate::trust::transparency_logs(trust);
let art_dir = pkg_dir.join(ARTIFACTS_DIR);
let sealed: std::collections::BTreeSet<&str> = receipt
.artifacts
.iter()
.map(|a| a.artifact_id.as_str())
.collect();
let mut verified: Vec<i64> = Vec::new();
let mut witnessed_artifacts = 0usize;
let mut bad: Vec<String> = Vec::new();
let mut untrusted_logs: std::collections::BTreeSet<String> = Default::default();
let mut unsealed: Vec<String> = Vec::new();
let Ok(entries) = std::fs::read_dir(&dir) else {
checks.push(VerifyCheck::fail(
"anchoring",
"anchors/ exists but cannot be read",
));
return;
};
let mut files: Vec<_> = entries.filter_map(|e| e.ok()).map(|e| e.path()).collect();
files.sort();
for path in files {
let Some(id) = path
.file_stem()
.and_then(|s| s.to_str())
.map(str::to_string)
else {
continue;
};
if !sealed.contains(id.as_str()) {
unsealed.push(id);
continue;
}
let parsed = std::fs::read(&path)
.ok()
.and_then(|b| serde_json::from_slice::<Vec<crate::storage::RecordAnchor>>(&b).ok());
let Some(anchors) = parsed else {
bad.push(format!("{id}: anchors file is not a list of anchors"));
continue;
};
let env = std::fs::read(art_dir.join(format!("{}.json", sanitize_filename(&id))))
.ok()
.and_then(|b| crate::attestation::Envelope::from_json(&b).ok());
let Some(env) = env else {
bad.push(format!(
"{id}: no signed envelope in the package to bind the proof to"
));
continue;
};
let mut any = false;
for a in anchors.iter().filter(|a| a.mechanism == "rekor") {
let Some(proof) = &a.proof else { continue };
match verify_rekor_entry(proof, &env, &logs) {
Ok(v) => {
verified.push(v.integrated_time);
any = true;
}
Err(RekorVerifyError::UnknownLog(log_id)) => {
untrusted_logs.insert(log_id);
}
Err(e) => bad.push(format!("{id}: {e}")),
}
}
if any {
witnessed_artifacts += 1;
}
}
if !bad.is_empty() {
checks.push(VerifyCheck::fail(
"anchoring",
&format!(
"{} stapled proof(s) do not hold: {}",
bad.len(),
bad.join("; ")
),
));
return;
}
if !untrusted_logs.is_empty() {
checks.push(VerifyCheck::warn(
"anchoring",
&format!(
"proofs come from a transparency log this machine does not trust (logID {}); pin it with `treeship trust add <label> @<log key>.pem --kind transparency_log` if you mean to",
untrusted_logs.into_iter().collect::<Vec<_>>().join(", ")
),
));
return;
}
if verified.is_empty() {
checks.push(VerifyCheck::warn(
"anchoring",
"anchors/ is present but carries no Rekor proof",
));
return;
}
let first = verified.iter().min().copied().unwrap_or_default();
let last = verified.iter().max().copied().unwrap_or_default();
let mut detail = format!(
"{witnessed_artifacts} of {} sealed artifact(s) carry a Rekor entry that verifies offline (Rekor time {} to {}); the rest rest on the signer's clock",
sealed.len(),
crate::statements::unix_to_rfc3339(first.max(0) as u64),
crate::statements::unix_to_rfc3339(last.max(0) as u64),
);
if !unsealed.is_empty() {
detail.push_str(&format!(
"; ignored proofs for artifacts not in the sealed set: {}",
unsealed.join(", ")
));
}
checks.push(VerifyCheck::pass("anchoring", &detail));
}
fn key_fingerprint(vk: &ed25519_dalek::VerifyingKey) -> String {
crate::statements::invitation::pubkey_fingerprint_short(&crate::trust::encode_ed25519_pubkey(
vk,
))
}
fn describe_trusted(
kid: &str,
vk: &ed25519_dalek::VerifyingKey,
trust: &crate::trust::TrustRootStore,
) -> String {
let fp = key_fingerprint(vk);
let root = trust.roots().iter().find(|r| {
SIGNER_KINDS.contains(&r.kind)
&& crate::trust::decode_ed25519_pubkey(&r.public_key)
.map(|k| k.to_bytes() == vk.to_bytes())
.unwrap_or(false)
});
match root {
Some(r) if r.label == OWN_KEY_LABEL => format!("{kid} (this ship's own, fp {fp})"),
Some(r) => format!(
"{kid} (pinned as {:?}, {}, fp {fp})",
r.label,
r.kind.as_str()
),
None => format!("{kid} (fp {fp})"),
}
}
fn colliding_key_ids(
pkg_dir: &Path,
trust: &crate::trust::TrustRootStore,
) -> std::collections::BTreeSet<String> {
package_verifying_keys(pkg_dir)
.into_iter()
.filter(|(kid, vk)| {
trust.roots().iter().any(|r| {
&r.key_id == kid
&& SIGNER_KINDS.contains(&r.kind)
&& crate::trust::decode_ed25519_pubkey(&r.public_key)
.map(|p| p.to_bytes() != vk.to_bytes())
.unwrap_or(false)
})
})
.map(|(kid, _)| kid)
.collect()
}
fn push_key_id_collisions(
pkg_dir: &Path,
trust: &crate::trust::TrustRootStore,
checks: &mut Vec<VerifyCheck>,
) {
let keys = package_verifying_keys(pkg_dir);
let mut clashes = Vec::new();
for (kid, vk) in &keys {
for r in trust.roots() {
if &r.key_id != kid || !SIGNER_KINDS.contains(&r.kind) {
continue;
}
let Ok(pinned) = crate::trust::decode_ed25519_pubkey(&r.public_key) else {
continue;
};
if pinned.to_bytes() != vk.to_bytes() {
clashes.push(format!(
"{kid}: the package's key has fp {}, the root pinned here as {kid} ({:?}) has fp {}",
key_fingerprint(vk),
r.label,
key_fingerprint(&pinned)
));
}
}
}
if !clashes.is_empty() {
checks.push(VerifyCheck::fail(
"key_id_collision",
&format!(
"a key id in this package names a different public key than the root pinned here under that id, so the package would read as someone else's: {}",
clashes.join("; ")
),
));
}
}
fn push_approval_evidence(
pkg_dir: &Path,
receipt: &SessionReceipt,
structural_only: bool,
checks: &mut Vec<VerifyCheck>,
) {
let consuming: Vec<(&str, String)> = receipt
.artifacts
.iter()
.filter_map(|a| {
let nonce = std::fs::read(
pkg_dir
.join(ARTIFACTS_DIR)
.join(format!("{}.json", sanitize_filename(&a.artifact_id))),
)
.ok()
.and_then(|raw| crate::attestation::Envelope::from_json(&raw).ok())
.and_then(|env| env.payload_bytes().ok())
.and_then(|b| serde_json::from_slice::<serde_json::Value>(&b).ok())
.and_then(|v| {
v.get("approvalNonce")
.and_then(|n| n.as_str())
.map(str::to_string)
})?;
Some((
a.artifact_id.as_str(),
crate::statements::nonce_digest(&nonce),
))
})
.collect();
if consuming.is_empty() {
return;
}
let bundle = read_approvals_bundle(pkg_dir).unwrap_or_default();
let covered: std::collections::BTreeSet<&str> = bundle
.uses
.iter()
.map(|u| u.nonce_digest.as_str())
.collect();
let missing: Vec<&str> = consuming
.iter()
.filter(|(_, d)| !covered.contains(d.as_str()))
.map(|(id, _)| *id)
.collect();
if missing.is_empty() {
checks.push(VerifyCheck::pass(
"approval_evidence",
&format!(
"{} approval-consuming action(s), each with its use record in approvals/",
consuming.len()
),
));
} else {
let detail = format!(
"{} sealed action(s) consume an approval but the package carries no use record for them in approvals/ ({}): the approval-use and replay checks cannot run",
missing.len(),
missing.join(", ")
);
checks.push(if structural_only {
VerifyCheck::warn("approval_evidence", &detail)
} else {
VerifyCheck::fail("approval_evidence", &detail)
});
}
}
fn push_chain_completeness(
receipt: &SessionReceipt,
sealed: &SealedSet,
authenticated: &std::collections::BTreeSet<String>,
checks: &mut Vec<VerifyCheck>,
) {
let unchained: Vec<&str> = receipt
.artifacts
.iter()
.filter(|a| a.unchained)
.map(|a| a.artifact_id.as_str())
.collect();
if unchained.is_empty() {
return;
}
let consumed_by_trusted = |nonce: &str| {
sealed
.consumers
.iter()
.any(|(n, k)| n == nonce && authenticated.contains(k))
};
let bound = |id: &str| -> Option<&'static str> {
if sealed
.approvals
.iter()
.any(|(a, n)| a == id && consumed_by_trusted(n))
{
return Some("approval nonce");
}
if sealed
.participants
.iter()
.any(|(p, _, host)| p == id && authenticated.contains(host))
{
return Some("countersigned participant");
}
if sealed
.participants
.iter()
.any(|(_, inv, host)| inv == id && authenticated.contains(host))
{
return Some("redeemed invitation");
}
if sealed.liveness.iter().any(|(l, part, sess, k)| {
l == id
&& sess == &receipt.session.id
&& authenticated.contains(k)
&& sealed
.participants
.iter()
.any(|(p, _, host)| p == part && authenticated.contains(host))
}) {
return Some("participant liveness");
}
None
};
let (bound_ids, loose): (Vec<&str>, Vec<&str>) =
unchained.iter().partition(|id| bound(id).is_some());
let order =
"their position relative to the chain is the signer's claim only (package order unproven)";
if loose.is_empty() {
let how: std::collections::BTreeSet<&str> =
bound_ids.iter().filter_map(|id| bound(id)).collect();
checks.push(VerifyCheck::pass(
"chain_completeness",
&format!(
"{} sealed artifact(s) are not chained but are bound by signed references from authenticated signers ({}): {}; {order}",
bound_ids.len(),
how.into_iter().collect::<Vec<_>>().join(", "),
bound_ids.join(", ")
),
));
} else {
let also = if bound_ids.is_empty() {
String::new()
} else {
format!(
"; {} more are bound by signed references ({})",
bound_ids.len(),
bound_ids.join(", ")
)
};
checks.push(VerifyCheck::warn(
"chain_completeness",
&format!(
"{} sealed artifact(s) were signed during the session but never chained onto it, and nothing an authenticated signer signed accounts for them ({}); signed and sealed, but {order}{also}",
loose.len(),
loose.join(", ")
),
));
}
}
fn push_approval_signer(
pkg_dir: &Path,
approvers: &std::collections::BTreeSet<String>,
trust: &crate::trust::TrustRootStore,
checks: &mut Vec<VerifyCheck>,
) {
if approvers.is_empty() {
return;
}
let keys = package_verifying_keys(pkg_dir);
let unpinned: Vec<&str> = approvers
.iter()
.map(String::as_str)
.filter(|k| {
keys.get(*k)
.is_none_or(|vk| !SIGNER_KINDS.iter().any(|kind| trust.contains(vk, *kind)))
})
.collect();
if unpinned.is_empty() {
checks.push(VerifyCheck::pass(
"approval_signer",
&format!(
"every approval is signed by a pinned key or this ship's own ({})",
approvers.iter().cloned().collect::<Vec<_>>().join(", ")
),
));
return;
}
let colliding = colliding_key_ids(pkg_dir, trust);
let pins: Vec<String> = unpinned
.iter()
.filter(|k| !colliding.contains(**k))
.filter_map(|k| {
let vk = keys.get(*k)?;
Some(format!(
"treeship trust add {k} {} --kind cert_issuer (fp {})",
crate::trust::encode_ed25519_pubkey(vk),
key_fingerprint(vk)
))
})
.collect();
checks.push(VerifyCheck::warn(
"approval_signer",
&format!(
"approval(s) signed by {} verify, but the approver key is not pinned here: an action consuming the approval is no more authorized than the approver is trusted. Pin it if you trust the approver: {}",
unpinned.join(", "),
pins.join("; ")
),
));
}
fn push_signer_trust(
pkg_dir: &Path,
signers: &std::collections::BTreeSet<String>,
vouched: Option<&Vouched>,
trust: &crate::trust::TrustRootStore,
checks: &mut Vec<VerifyCheck>,
) -> std::collections::BTreeSet<String> {
if signers.is_empty() {
return Default::default();
}
let keys = package_verifying_keys(pkg_dir);
let trusted = |k: &str| {
keys.get(k)
.is_some_and(|vk| SIGNER_KINDS.iter().any(|kind| trust.contains(vk, *kind)))
};
let authenticated =
|k: &str| trusted(k) || vouched.is_some_and(|v| v.key == k && trusted(&v.by));
let unauthenticated: Vec<&str> = signers
.iter()
.map(String::as_str)
.filter(|k| !authenticated(k))
.collect();
let trusted_keys: Vec<&str> = signers
.iter()
.map(String::as_str)
.filter(|k| trusted(k))
.collect();
let colliding = colliding_key_ids(pkg_dir, trust);
let pins = |ks: &[&str]| -> String {
ks.iter()
.filter(|k| !colliding.contains(**k))
.filter_map(|k| {
let vk = keys.get(*k)?;
Some(format!(
"treeship trust add {k} {} --kind cert_issuer (fp {})",
crate::trust::encode_ed25519_pubkey(vk),
key_fingerprint(vk)
))
})
.collect::<Vec<_>>()
.join("; ")
};
if unauthenticated.is_empty() {
let own: Vec<&str> = signers
.iter()
.filter(|k| {
trust
.roots()
.iter()
.any(|r| &r.key_id == *k && r.label == OWN_KEY_LABEL)
})
.map(|k| k.as_str())
.collect();
let mut detail = if own.len() == signers.len() {
format!(
"all {} signing key(s) are this ship's own ({}); a stranger pins them before this row passes on their machine",
signers.len(),
own.join(", ")
)
} else if own.is_empty() {
format!(
"all {} signing key(s) are pinned trust roots",
signers.len()
)
} else {
format!(
"{} signing key(s): {} pinned trust root(s), {} this ship's own ({})",
signers.len(),
signers.len() - own.len(),
own.len(),
own.join(", ")
)
};
let matched: Vec<String> = signers
.iter()
.filter(|k| trusted(k))
.filter_map(|k| keys.get(k).map(|vk| describe_trusted(k, vk, trust)))
.collect();
if !matched.is_empty() {
detail.push_str(&format!("; matched: {}", matched.join(", ")));
}
if let Some(v) = vouched.filter(|v| !trusted(&v.key)) {
detail.push_str(&format!(
"; {} is not pinned but is the record key the session.close signed by {} names",
v.key, v.by
));
}
checks.push(VerifyCheck::pass("signer_trust", &detail));
} else if trusted_keys.is_empty() {
checks.push(VerifyCheck::warn(
"signer_trust",
&format!(
"signature(s) verify for the key(s) the package names, but none of them is a pinned trust root here: {}. Pin what you have decided to trust: {}",
unauthenticated.join(", "),
pins(&unauthenticated)
),
));
} else {
checks.push(VerifyCheck::fail(
"signer_trust",
&format!(
"{} is pinned here, but {} also signed sealed artifacts and is neither pinned nor the record key a trusted session.close names: an artifact signed by a key nobody vouched for sits in a package you trust. If you have decided to trust it: {}",
trusted_keys.join(", "),
unauthenticated.join(", "),
pins(&unauthenticated)
),
));
}
signers
.iter()
.filter(|k| authenticated(k))
.cloned()
.collect()
}
#[derive(Default)]
struct SealedSet {
signers: std::collections::BTreeSet<String>,
approval_signers: std::collections::BTreeSet<String>,
closes: Vec<SealedClose>,
starts: Vec<SealedClose>,
approvals: Vec<(String, String)>,
consumers: Vec<(String, String)>,
participants: Vec<(String, String, String)>,
liveness: Vec<(String, String, String, String)>,
}
struct SealedClose {
id: String,
keyid: String,
chained: bool,
statement: serde_json::Value,
}
fn verify_sealed_participant(
art_dir: &Path,
receipt: &SessionReceipt,
entry: &ArtifactEntry,
envelope: &crate::attestation::Envelope,
keys: &std::collections::BTreeMap<String, ed25519_dalek::VerifyingKey>,
) -> Result<SealedParticipant, String> {
use crate::statements::invitation::InvitationStatement;
use crate::statements::session_participant::{
verify_participant_artifact, SessionParticipantStatement,
};
let stmt: SessionParticipantStatement = envelope
.unmarshal_statement()
.map_err(|e| format!("participant payload invalid: {e}"))?;
let inv_id = &stmt.invitation_ref;
if !receipt.artifacts.iter().any(|a| &a.artifact_id == inv_id) {
return Err(format!(
"participant redeems invitation {inv_id}, which is not sealed in this package"
));
}
let raw = std::fs::read(art_dir.join(format!("{}.json", sanitize_filename(inv_id)))).map_err(
|_| format!("invitation {inv_id} is sealed but its envelope is not in the package"),
)?;
let invitation = crate::attestation::Envelope::from_json(&raw)
.map_err(|e| format!("invitation {inv_id} envelope does not parse: {e}"))?;
let inv_keyid = invitation
.signatures
.first()
.map(|s| s.keyid.clone())
.ok_or_else(|| format!("invitation {inv_id} carries no signature"))?;
let inv_key = keys.get(&inv_keyid).ok_or_else(|| {
format!("invitation {inv_id} is signed by {inv_keyid}, a key the package does not carry")
})?;
verify_participant_artifact(
envelope,
&entry.artifact_id,
&invitation,
*inv_key,
&receipt.session.id,
)
.map_err(|e| e.to_string())?;
if let Some(listed) = entry.digest.as_deref() {
use sha2::{Digest, Sha256};
let bytes = envelope
.to_json()
.map_err(|e| format!("participant envelope encoding failed: {e}"))?;
let actual = format!("sha256:{}", hex::encode(Sha256::digest(bytes)));
if listed != actual {
return Err(format!(
"receipt lists digest {listed} but the countersigned envelope digests to {actual}"
));
}
}
let max_uses = invitation
.unmarshal_statement::<InvitationStatement>()
.map_err(|e| format!("invitation {inv_id} payload invalid: {e}"))?
.max_uses;
Ok(SealedParticipant {
detail: format!(
"joining agent and host countersign verify over the participant's canonical bytes; the host key {inv_keyid} issued sealed invitation {inv_id}; id re-derived from the pending envelope"
),
host_keyid: inv_keyid,
invitation_ref: inv_id.clone(),
max_uses,
})
}
struct SealedParticipant {
host_keyid: String,
invitation_ref: String,
max_uses: u32,
detail: String,
}
fn verify_sealed_envelopes(
pkg_dir: &Path,
receipt: &SessionReceipt,
structural_only: bool,
checks: &mut Vec<VerifyCheck>,
) -> SealedSet {
use std::collections::{BTreeMap, BTreeSet};
if receipt.artifacts.is_empty() {
return SealedSet::default();
}
let art_dir = pkg_dir.join(ARTIFACTS_DIR);
let any_envelope = receipt.artifacts.iter().any(|a| {
art_dir
.join(format!("{}.json", sanitize_filename(&a.artifact_id)))
.exists()
});
if !any_envelope {
let detail = "the package carries no artifact envelopes (built before 0.31.2), so nothing here is signature-checked: the sealed set is structurally consistent and nothing more. Verify the artifacts from the producer's store, a bundle, or the hub with `treeship verify <id>`, or read structure only with --structural (verdict: structural-pass)";
checks.push(if structural_only {
VerifyCheck::warn("envelopes", detail)
} else {
VerifyCheck::fail("envelopes", detail)
});
return SealedSet::default();
}
let mut keys: BTreeMap<String, ed25519_dalek::VerifyingKey> = BTreeMap::new();
match read_package_keys(pkg_dir) {
Some(pk) => {
for (id, encoded) in pk.keys {
match crate::trust::decode_ed25519_pubkey(&encoded) {
Ok(vk) => {
keys.insert(id, vk);
}
Err(e) => checks.push(VerifyCheck::fail(
"keys.json",
&format!("key {id} is not a valid ed25519 public key: {e}"),
)),
}
}
}
None => checks.push(VerifyCheck::fail(
"keys.json",
"package has artifact envelopes but no keys.json naming the signing keys",
)),
}
let mut parents: Vec<(String, Option<SignedParent>)> = Vec::new();
let mut signers: BTreeSet<String> = BTreeSet::new();
let mut closes: Vec<SealedClose> = Vec::new();
let mut starts: Vec<SealedClose> = Vec::new();
let mut approval_signers: BTreeSet<String> = BTreeSet::new();
let mut approvals: Vec<(String, String)> = Vec::new();
let mut consumers: Vec<(String, String)> = Vec::new();
let mut participant_rows: Vec<(usize, String, String, String)> = Vec::new();
let mut liveness: Vec<(String, String, String, String)> = Vec::new();
let mut seen_ids: BTreeSet<&str> = BTreeSet::new();
let mut redemptions: BTreeMap<String, (u32, Vec<usize>)> = BTreeMap::new();
for entry in &receipt.artifacts {
let id = &entry.artifact_id;
let name = format!("signature:{id}");
if !seen_ids.insert(id.as_str()) {
checks.push(VerifyCheck::fail(
&name,
"sealed more than once in this package",
));
continue;
}
let path = art_dir.join(format!("{}.json", sanitize_filename(id)));
let raw = match std::fs::read(&path) {
Ok(b) => b,
Err(_) => {
checks.push(VerifyCheck::fail(
&name,
"sealed in the Merkle tree but its signed envelope is not in the package",
));
parents.push((id.clone(), None));
continue;
}
};
let envelope = match crate::attestation::Envelope::from_json(&raw) {
Ok(e) => e,
Err(e) => {
checks.push(VerifyCheck::fail(
&name,
&format!("envelope does not parse: {e}"),
));
parents.push((id.clone(), None));
continue;
}
};
if envelope.payload_type == crate::statements::payload_type("session-participant") {
match verify_sealed_participant(&art_dir, receipt, entry, &envelope, &keys) {
Ok(p) => {
signers.insert(p.host_keyid.clone());
participant_rows.push((
checks.len(),
id.clone(),
p.invitation_ref.clone(),
p.host_keyid,
));
let slot = redemptions
.entry(p.invitation_ref)
.or_insert((p.max_uses, Vec::new()));
slot.1.push(checks.len());
checks.push(VerifyCheck::pass(&name, &p.detail));
}
Err(detail) => checks.push(VerifyCheck::fail(&name, &detail)),
}
parents.push((id.clone(), None));
continue;
}
let Some(sig) = envelope.signatures.first() else {
checks.push(VerifyCheck::fail(&name, "envelope carries no signature"));
parents.push((id.clone(), None));
continue;
};
let Some(vk) = keys.get(&sig.keyid) else {
checks.push(VerifyCheck::fail(
&name,
&format!("signed by {}, a key the package does not carry", sig.keyid),
));
parents.push((id.clone(), None));
continue;
};
match crate::attestation::verify_with_key(&envelope, &sig.keyid, *vk) {
Ok(res) => {
if res.artifact_id != *id {
checks.push(VerifyCheck::fail(
&name,
&format!(
"the signed bytes re-derive to {}, not the sealed id",
res.artifact_id
),
));
} else if entry
.digest
.as_deref()
.map(|d| d != res.digest)
.unwrap_or(false)
{
checks.push(VerifyCheck::fail(
&name,
&format!(
"receipt lists digest {} but the signed bytes digest to {}",
entry.digest.clone().unwrap_or_default(),
res.digest
),
));
} else {
let body = envelope
.payload_bytes()
.ok()
.and_then(|b| serde_json::from_slice::<serde_json::Value>(&b).ok());
if envelope.payload_type == crate::statements::payload_type("approval") {
approval_signers.insert(sig.keyid.clone());
if let Some(n) = body
.as_ref()
.and_then(|v| v.get("nonce"))
.and_then(|n| n.as_str())
{
approvals.push((id.clone(), n.to_string()));
}
} else {
signers.insert(sig.keyid.clone());
if envelope.payload_type
== crate::statements::payload_type("session-liveness")
{
let field = |k: &str| {
body.as_ref()
.and_then(|v| v.get(k))
.and_then(|x| x.as_str())
.unwrap_or("")
.to_string()
};
liveness.push((
id.clone(),
field("participant_ref"),
field("session_ref"),
sig.keyid.clone(),
));
}
if !entry.unchained {
if let Some(n) = body
.as_ref()
.and_then(|v| v.get("approvalNonce"))
.and_then(|n| n.as_str())
{
consumers.push((n.to_string(), sig.keyid.clone()));
}
}
}
if envelope.payload_type == crate::statements::payload_type("action") {
if let Some(v) = envelope
.payload_bytes()
.ok()
.and_then(|b| serde_json::from_slice::<serde_json::Value>(&b).ok())
{
let action =
v.get("action").and_then(|a| a.as_str()).map(str::to_string);
let sealed = SealedClose {
id: id.clone(),
keyid: sig.keyid.clone(),
chained: !entry.unchained,
statement: v,
};
match action.as_deref() {
Some("session.close") => closes.push(sealed),
Some("session.start") => starts.push(sealed),
_ => {}
}
}
}
checks.push(VerifyCheck::pass(&name, &format!("Ed25519 signature by {} verifies; id and digest re-derived from the signed bytes", sig.keyid)));
}
}
Err(e) => checks.push(VerifyCheck::fail(
&name,
&format!("invalid signature for key {}: {e}", sig.keyid),
)),
}
let parent = envelope
.payload_bytes()
.ok()
.and_then(|b| serde_json::from_slice::<serde_json::Value>(&b).ok())
.map(|v| signed_parent(&v));
parents.push((id.clone(), parent));
}
for (inv, (max_uses, rows)) in &redemptions {
if rows.len() > *max_uses as usize {
for &i in rows.iter().skip(*max_uses as usize) {
let detail = format!(
"invitation {inv} redeemed {} times in this package, max_uses {max_uses}",
rows.len()
);
checks[i] = VerifyCheck::fail(&checks[i].name.clone(), &detail);
}
}
}
let chained: Vec<(usize, &ArtifactEntry)> = receipt
.artifacts
.iter()
.enumerate()
.filter(|(_, a)| !a.unchained)
.collect();
let mut broken: Vec<String> = Vec::new();
let mut legacy: Vec<&str> = Vec::new();
for w in chained.windows(2) {
let (_, prev) = w[0];
let (_, cur) = w[1];
let parent = parents
.iter()
.find(|(id, _)| *id == cur.artifact_id)
.and_then(|(_, p)| p.clone());
match parent {
Some(SignedParent::Named(p)) if p == prev.artifact_id => {}
Some(SignedParent::Named(p)) => broken.push(format!(
"{} names parent {} but follows {}",
cur.artifact_id, p, prev.artifact_id
)),
Some(SignedParent::LegacyEndorsement) => legacy.push(cur.artifact_id.as_str()),
Some(SignedParent::None) | None => {
broken.push(format!("{} has no readable parentId", cur.artifact_id))
}
}
}
if chained.len() >= 2 {
if !broken.is_empty() {
checks.push(VerifyCheck::fail("chain_linkage", &broken.join("; ")));
} else if !legacy.is_empty() {
checks.push(VerifyCheck::warn(
"chain_linkage",
&format!(
"{} chained artifacts; every signed parent matches, but {} endorsement(s) made by 0.31.9 or earlier sign no parent, so their place in the chain is the producer's claim only: {}",
chained.len(),
legacy.len(),
legacy.join(", ")
),
));
} else {
checks.push(VerifyCheck::pass("chain_linkage", &format!("{} chained artifacts each name the previous one as parent, inside the signature", chained.len())));
}
}
let participants = participant_rows
.into_iter()
.filter(|(i, ..)| checks[*i].status == VerifyStatus::Pass)
.map(|(_, id, inv, host)| (id, inv, host))
.collect();
SealedSet {
signers,
approval_signers,
closes,
starts,
approvals,
consumers,
participants,
liveness,
}
}
fn finish_package_checks(
mut checks: Vec<VerifyCheck>,
receipt: &SessionReceipt,
) -> Vec<VerifyCheck> {
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
}
pub(crate) fn add_approval_evidence_checks(
checks: &mut Vec<VerifyCheck>,
bundle: &ApprovalsBundle,
trust: &crate::trust::TrustRootStore,
) {
if bundle.uses.is_empty() && bundle.checkpoints.is_empty() {
return;
}
use std::collections::HashMap;
let mut by_nonce: HashMap<(String, String), Vec<&ApprovalUse>> = HashMap::new();
let mut by_use_id: HashMap<&str, Vec<&ApprovalUse>> = HashMap::new();
for u in &bundle.uses {
by_nonce
.entry((u.grant_id.clone(), u.nonce_digest.clone()))
.or_default()
.push(u);
by_use_id.entry(&u.use_id).or_default().push(u);
}
let over_max: Vec<((String, String), Vec<&ApprovalUse>, u32)> = by_nonce
.iter()
.filter_map(|(key, uses)| {
let max = uses.iter().filter_map(|u| u.max_uses).next()?;
if (uses.len() as u32) > max {
Some((key.clone(), uses.to_vec(), max))
} else {
None
}
})
.collect();
let dup_use_ids: Vec<(&&str, &Vec<&ApprovalUse>)> =
by_use_id.iter().filter(|(_, v)| v.len() > 1).collect();
if over_max.is_empty() && dup_use_ids.is_empty() {
checks.push(VerifyCheck::pass(
"replay-package-local",
&format!(
"no duplicate approval use inside package ({} uses scanned)",
bundle.uses.len()
),
));
} else {
let mut detail = String::from("package-local replay violation:");
for ((grant_id, _nd), uses, max) in &over_max {
detail.push_str(&format!(
" grant {grant_id} consumed {} times in this package (max_uses={max});",
uses.len(),
));
}
for (uid, uses) in &dup_use_ids {
detail.push_str(&format!(" use_id {uid} appears {} times;", uses.len()));
}
checks.push(VerifyCheck::fail("replay-package-local", &detail));
}
if !bundle.checkpoints.is_empty() {
let mut tampered = Vec::new();
for cp in &bundle.checkpoints {
let recomputed = journal_checkpoint_record_digest(cp);
if recomputed != cp.record_digest {
tampered.push((
cp.checkpoint_id.clone(),
cp.record_digest.clone(),
recomputed,
));
}
}
if tampered.is_empty() {
checks.push(VerifyCheck::pass(
"replay-included-checkpoint",
&format!(
"{} included journal checkpoint(s) verify offline",
bundle.checkpoints.len()
),
));
} else {
let detail = tampered
.iter()
.map(|(id, expected, actual)| {
format!("checkpoint {id} tampered (stored {expected}, recomputed {actual})")
})
.collect::<Vec<_>>()
.join("; ");
checks.push(VerifyCheck::fail("replay-included-checkpoint", &detail));
}
}
let mut tampered_uses = Vec::new();
for u in &bundle.uses {
let recomputed = approval_use_record_digest(u);
if recomputed != u.record_digest {
tampered_uses.push((u.use_id.clone(), u.record_digest.clone(), recomputed));
}
}
if !bundle.uses.is_empty() {
if tampered_uses.is_empty() {
checks.push(VerifyCheck::pass(
"approval-use-record-digest",
&format!("{} use record(s) recompute identically", bundle.uses.len()),
));
} else {
let detail = tampered_uses
.iter()
.map(|(id, expected, actual)| {
format!("use {id} tampered (stored {expected}, recomputed {actual})")
})
.collect::<Vec<_>>()
.join("; ");
checks.push(VerifyCheck::fail("approval-use-record-digest", &detail));
}
}
if !bundle.uses.is_empty() {
use crate::attestation::envelope::Envelope;
use crate::attestation::{artifact_id_from_pae, pae};
use crate::statements::{nonce_digest, ApprovalStatement};
let mut grant_nonce_digest: std::collections::HashMap<String, String> =
std::collections::HashMap::new();
let mut tampered_grants: Vec<String> = Vec::new();
for (grant_id, env_bytes) in &bundle.grants {
let env = match Envelope::from_json(env_bytes) {
Ok(e) => e,
Err(_) => {
tampered_grants.push(format!("grant {grant_id} envelope unparseable"));
continue;
}
};
let derived = match env.payload_bytes() {
Ok(p) => artifact_id_from_pae(&pae(&env.payload_type, &p)),
Err(_) => {
tampered_grants.push(format!("grant {grant_id} envelope payload undecodable"));
continue;
}
};
if &derived != grant_id {
tampered_grants.push(format!(
"grant {grant_id} envelope content derives to {derived} -- envelope substituted or tampered",
));
continue;
}
let approval: ApprovalStatement = match env.unmarshal_statement() {
Ok(a) => a,
Err(_) => {
tampered_grants
.push(format!("grant {grant_id} payload not an ApprovalStatement"));
continue;
}
};
grant_nonce_digest.insert(grant_id.clone(), nonce_digest(&approval.nonce));
}
let mut mismatches: Vec<String> = Vec::new();
let mut missing_grants: Vec<String> = Vec::new();
for u in &bundle.uses {
match grant_nonce_digest.get(&u.grant_id) {
Some(expected) => {
if expected != &u.nonce_digest {
mismatches.push(format!(
"use {} claims nonce_digest {} but grant {} signed nonce hashes to {}",
u.use_id, u.nonce_digest, u.grant_id, expected,
));
}
}
None => {
missing_grants.push(format!(
"use {} references grant {} but no usable grant envelope is in the package",
u.use_id, u.grant_id,
));
}
}
}
if mismatches.is_empty() && missing_grants.is_empty() && tampered_grants.is_empty() {
checks.push(VerifyCheck::pass(
"approval-use-nonce-binding",
&format!(
"{} use record(s) bind to content-addressed grant signed nonces",
bundle.uses.len(),
),
));
} else {
let mut parts: Vec<String> = Vec::new();
if !tampered_grants.is_empty() {
parts.push(tampered_grants.join("; "));
}
if !mismatches.is_empty() {
parts.push(mismatches.join("; "));
}
if !missing_grants.is_empty() {
parts.push(missing_grants.join("; "));
}
checks.push(VerifyCheck::fail(
"approval-use-nonce-binding",
&parts.join("; "),
));
}
}
if !bundle.uses.is_empty() {
use crate::attestation::envelope::Envelope;
use crate::attestation::{artifact_id_from_pae, pae};
use crate::statements::{nonce_digest, ActionStatement};
if bundle.action_envelopes.is_empty() {
checks.push(VerifyCheck::warn(
"approval-use-action-binding",
"no action envelopes embedded -- action↔use binding not asserted by package (pre-v0.9.10)",
));
} else {
let use_ids: std::collections::HashSet<&str> =
bundle.uses.iter().map(|u| u.use_id.as_str()).collect();
let mut violations: Vec<String> = Vec::new();
let mut bound_count = 0usize;
let mut not_actions = 0usize;
let action_v1 = crate::statements::payload_type("action");
for (artifact_id, env_bytes) in &bundle.action_envelopes {
let env = match Envelope::from_json(env_bytes) {
Ok(e) => e,
Err(_) => {
violations.push(format!("action {artifact_id} envelope unparseable"));
continue;
}
};
if env.payload_type != action_v1 {
not_actions += 1;
continue;
}
let derived = match env.payload_bytes() {
Ok(p) => artifact_id_from_pae(&pae(&env.payload_type, &p)),
Err(_) => {
violations
.push(format!("action {artifact_id} envelope payload undecodable"));
continue;
}
};
if &derived != artifact_id {
violations.push(format!(
"action {artifact_id} envelope content derives to {derived} -- envelope substituted or tampered",
));
continue;
}
let action: ActionStatement = match env.unmarshal_statement() {
Ok(a) => a,
Err(_) => {
violations.push(format!("action {artifact_id} not an ActionStatement"));
continue;
}
};
let raw_nonce = match action.approval_nonce.as_deref() {
Some(n) => n,
None => continue,
};
let claimed_use_id = action
.meta
.as_ref()
.and_then(|m| m.get("approval_use_id"))
.and_then(|v| v.as_str());
let Some(claimed_use_id) = claimed_use_id else {
violations.push(format!(
"action {artifact_id} consumed an approval but its meta has no approval_use_id"
));
continue;
};
if !use_ids.contains(claimed_use_id) {
violations.push(format!(
"action {artifact_id} claims approval_use_id={} but no such use is embedded",
claimed_use_id,
));
continue;
}
let expected = nonce_digest(raw_nonce);
let matched_use = bundle.uses.iter().find(|u| u.use_id == claimed_use_id);
if let Some(u) = matched_use {
if u.nonce_digest != expected {
violations.push(format!(
"action {artifact_id} approval_nonce hashes to {} but use {} stores nonce_digest {}",
expected, claimed_use_id, u.nonce_digest,
));
continue;
}
if u.actor != action.actor || u.action != action.action {
violations.push(format!(
"use {} records {} doing {} but the signed action {artifact_id} is {} doing {}",
claimed_use_id, u.actor, u.action, action.actor, action.action,
));
continue;
}
}
bound_count += 1;
}
if violations.is_empty() {
checks.push(VerifyCheck::pass(
"approval-use-action-binding",
&format!(
"{bound_count} consuming action(s) bind cleanly to content-addressed envelope(s){}",
if not_actions > 0 {
format!("; {not_actions} non-action envelope(s) not subject to this row")
} else {
String::new()
}
),
));
} else {
checks.push(VerifyCheck::fail(
"approval-use-action-binding",
&violations.join("; "),
));
}
}
}
if !bundle.uses.is_empty() || !bundle.checkpoints.is_empty() {
use std::collections::{HashMap, HashSet};
struct Node<'a> {
label: String,
digest: &'a str,
prev: &'a str,
}
let mut nodes: Vec<Node> = Vec::new();
for u in &bundle.uses {
nodes.push(Node {
label: format!("use {}", u.use_id),
digest: u.record_digest.as_str(),
prev: u.previous_record_digest.as_str(),
});
}
for cp in &bundle.checkpoints {
nodes.push(Node {
label: format!("checkpoint {}", cp.checkpoint_id),
digest: cp.record_digest.as_str(),
prev: cp.previous_record_digest.as_str(),
});
}
let owned: HashSet<&str> = std::iter::once("")
.chain(nodes.iter().map(|n| n.digest))
.collect();
let mut violations: Vec<String> = Vec::new();
for n in &nodes {
if !owned.contains(n.prev) {
violations.push(format!(
"{} previous_record_digest {} not anchored in package",
n.label, n.prev,
));
}
}
let genesis: Vec<&Node> = nodes.iter().filter(|n| n.prev.is_empty()).collect();
if genesis.len() > 1 {
violations.push(format!(
"{} records claim previous_record_digest='' (genesis): {}",
genesis.len(),
genesis
.iter()
.map(|n| n.label.clone())
.collect::<Vec<_>>()
.join(", "),
));
}
let mut by_prev: HashMap<&str, Vec<&Node>> = HashMap::new();
for n in &nodes {
by_prev.entry(n.prev).or_default().push(n);
}
for (prev, group) in &by_prev {
if group.len() > 1 && !prev.is_empty() {
violations.push(format!(
"fork: {} records share previous_record_digest {}: {}",
group.len(),
prev,
group
.iter()
.map(|n| n.label.clone())
.collect::<Vec<_>>()
.join(", "),
));
}
}
if violations.is_empty() {
let by_digest: HashMap<&str, &Node> = nodes.iter().map(|n| (n.digest, n)).collect();
let next_of: HashMap<&str, &Node> = nodes
.iter()
.filter(|n| !n.prev.is_empty())
.map(|n| (n.prev, n))
.collect();
let start = genesis.first().copied();
let mut visited: HashSet<&str> = HashSet::new();
let mut current = start;
while let Some(node) = current {
if !visited.insert(node.digest) {
violations.push(format!(
"cycle detected at {} (record_digest {})",
node.label, node.digest,
));
break;
}
current = next_of.get(node.digest).copied();
}
if violations.is_empty() && visited.len() != nodes.len() {
let unreached: Vec<String> = nodes
.iter()
.filter(|n| !visited.contains(n.digest))
.map(|n| n.label.clone())
.collect();
if !unreached.is_empty() {
violations.push(format!(
"disconnected subchain: {} record(s) not reachable from genesis: {}",
unreached.len(),
unreached.join(", "),
));
}
}
let _ = by_digest; }
if violations.is_empty() {
checks.push(VerifyCheck::pass(
"approval-use-chain-continuity",
&format!(
"{} record(s) form a single connected linked list from one genesis with no cycles or forks",
nodes.len(),
),
));
} else {
checks.push(VerifyCheck::fail(
"approval-use-chain-continuity",
&violations.join("; "),
));
}
}
let hub_checkpoints: Vec<&JournalCheckpoint> = bundle
.checkpoints
.iter()
.filter(|cp| cp.checkpoint_kind == crate::statements::CheckpointKind::HubOrg)
.collect();
if !hub_checkpoints.is_empty() {
let mut all_ok = true;
let mut details: Vec<String> = Vec::new();
let mut have_valid_signature = false;
let mut security_fatal = false;
for cp in &hub_checkpoints {
match crate::statements::verify_hub_checkpoint_signature(cp, trust) {
crate::statements::HubCheckpointVerification::Valid => {
have_valid_signature = true;
let covered: std::collections::HashSet<&String> =
cp.covered_use_ids.iter().collect();
let missing: Vec<String> = bundle
.uses
.iter()
.filter(|u| !covered.contains(&u.use_id))
.map(|u| u.use_id.clone())
.collect();
if missing.is_empty() {
details.push(format!(
"{} signed by {} verifies; covers {} use(s)",
cp.checkpoint_id,
cp.hub_id,
cp.covered_use_ids.len(),
));
} else {
all_ok = false;
details.push(format!(
"{} verifies but does not cover {} use(s): {}",
cp.checkpoint_id,
missing.len(),
missing.join(", "),
));
}
}
crate::statements::HubCheckpointVerification::MissingFields(field) => {
all_ok = false;
details.push(format!(
"{} declares kind=hub-org but field `{}` is missing",
cp.checkpoint_id, field,
));
}
crate::statements::HubCheckpointVerification::Tampered => {
all_ok = false;
security_fatal = true;
details.push(format!(
"{} hub signature failed verification (tampered or wrong key)",
cp.checkpoint_id,
));
}
crate::statements::HubCheckpointVerification::NotHubKind => {
all_ok = false;
security_fatal = true;
details.push(format!(
"{} kind toggled out of hub-org during verify",
cp.checkpoint_id,
));
}
crate::statements::HubCheckpointVerification::UntrustedIssuer => {
all_ok = false;
security_fatal = true;
details.push(format!(
"{} hub_public_key is not a trusted root (configure via `treeship trust add`)",
cp.checkpoint_id,
));
}
}
}
if all_ok && have_valid_signature {
checks.push(VerifyCheck::pass("replay-hub-org", &details.join("; ")));
} else if security_fatal {
checks.push(VerifyCheck::fail("replay-hub-org", &details.join("; ")));
} else {
checks.push(VerifyCheck::warn("replay-hub-org", &details.join("; ")));
}
}
let _ = ReplayCheckLevel::HubOrg;
let _ = approval_revocation_record_digest as fn(&ApprovalRevocation) -> String;
let _ = ReplayCheck::not_performed;
}
#[derive(Debug, Clone)]
pub struct VerifyCheck {
pub name: String,
pub status: VerifyStatus,
pub detail: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum VerifyStatus {
Pass,
Fail,
Warn,
}
impl VerifyCheck {
pub fn pass(name: &str, detail: &str) -> Self {
Self {
name: name.into(),
status: VerifyStatus::Pass,
detail: detail.into(),
}
}
pub fn fail(name: &str, detail: &str) -> Self {
Self {
name: name.into(),
status: VerifyStatus::Fail,
detail: detail.into(),
}
}
pub fn warn(name: &str, detail: &str) -> Self {
Self {
name: name.into(),
status: VerifyStatus::Warn,
detail: detail.into(),
}
}
}
impl VerifyCheck {
pub fn passed(&self) -> bool {
self.status == VerifyStatus::Pass
}
}
const PREVIEW_TEMPLATE: &str = include_str!("preview_template.html");
const FRAUNCES_WOFF2: &[u8] = include_bytes!("../../assets/fonts/fraunces-latin-var.woff2");
fn fraunces_data_uri() -> String {
use base64::engine::general_purpose::STANDARD;
use base64::Engine;
format!("data:font/woff2;base64,{}", STANDARD.encode(FRAUNCES_WOFF2))
}
pub fn render_preview_html(receipt: &SessionReceipt) -> String {
render_preview_html_with_approvals(receipt, None)
}
pub fn preview_approvals_json(bundle: Option<&ApprovalsBundle>) -> serde_json::Value {
let Some(b) = bundle else {
return serde_json::Value::Null;
};
if b.grants.is_empty() && b.uses.is_empty() {
return serde_json::Value::Null;
}
let grants: Vec<serde_json::Value> = b
.grants
.iter()
.map(|(grant_id, bytes)| {
let parsed = crate::attestation::Envelope::from_json(bytes)
.ok()
.and_then(|env| env.unmarshal_statement::<ApprovalStatement>().ok());
match parsed {
Some(st) => serde_json::json!({
"grant_id": grant_id,
"parsed": true,
"approver": st.approver,
"description": st.description,
"timestamp": st.timestamp,
"expires_at": st.expires_at,
"scope": st.scope.as_ref().map(|sc| serde_json::json!({
"allowed_actors": sc.allowed_actors,
"allowed_actions": sc.allowed_actions,
"allowed_subjects": sc.allowed_subjects,
"max_uses": sc.max_actions,
"valid_until": sc.valid_until,
})),
}),
None => serde_json::json!({ "grant_id": grant_id, "parsed": false }),
}
})
.collect();
let uses: Vec<serde_json::Value> = b
.uses
.iter()
.map(|u| serde_json::to_value(u).unwrap_or(serde_json::Value::Null))
.collect();
serde_json::json!({ "grants": grants, "uses": uses })
}
pub fn render_preview_html_with_approvals(
receipt: &SessionReceipt,
bundle: Option<&ApprovalsBundle>,
) -> String {
let approvals_json = preview_approvals_json(bundle).to_string();
let safe_approvals = approvals_json.replace('<', r"\u003c");
let receipt_json = serde_json::to_string_pretty(receipt).unwrap_or_else(|_| "{}".to_string());
let safe_json = receipt_json.replace('<', r"\u003c");
PREVIEW_TEMPLATE
.replacen("__RECEIPT_JSON__", &safe_json, 1)
.replacen("__APPROVALS_JSON__", &safe_approvals, 1)
.replace("__FONT_FRAUNCES__", &fraunces_data_uri())
}
fn coverage_check(pkg_dir: &Path, receipt: &SessionReceipt) -> VerifyCheck {
let art_dir = pkg_dir.join(ARTIFACTS_DIR);
for entry in &receipt.artifacts {
let path = art_dir.join(format!("{}.json", sanitize_filename(&entry.artifact_id)));
let Ok(raw) = std::fs::read(&path) else {
continue;
};
let Ok(env) = crate::attestation::Envelope::from_json(&raw) else {
continue;
};
let Some(stmt) = env
.payload_bytes()
.ok()
.and_then(|b| serde_json::from_slice::<serde_json::Value>(&b).ok())
else {
continue;
};
if stmt.get("kind").and_then(|k| k.as_str()) != Some("coverage.v1") {
continue;
}
let Some(p) = stmt.get("payload") else {
continue;
};
let level = p
.get("declared_level")
.and_then(|v| v.as_str())
.unwrap_or("?");
let harnesses: Vec<String> = p
.get("harnesses")
.and_then(|h| h.as_array())
.map(|arr| {
arr.iter()
.map(|h| {
let id = h.get("harness_id").and_then(|v| v.as_str()).unwrap_or("?");
let modes: Vec<&str> = h
.get("connection_modes")
.and_then(|m| m.as_array())
.map(|m| m.iter().filter_map(|x| x.as_str()).collect())
.unwrap_or_default();
if modes.is_empty() {
id.to_string()
} else {
format!("{id} via {}", modes.join("+"))
}
})
.collect()
})
.unwrap_or_default();
let events = p
.get("observed")
.and_then(|o| o.get("events"))
.and_then(|v| v.as_u64())
.unwrap_or(0);
let types = p
.get("observed")
.and_then(|o| o.get("event_types"))
.and_then(|t| t.as_object())
.map(|m| m.len())
.unwrap_or(0);
let gaps = p
.get("gaps")
.and_then(|g| g.as_array())
.map(|g| g.len())
.unwrap_or(0);
let via = if harnesses.is_empty() {
"no harness state".to_string()
} else {
harnesses.join(", ")
};
return VerifyCheck::pass(
"coverage",
&format!(
"{}: declared {level} ({via}); {events} events observed across {types} types; {gaps} stated gap(s). A declared level is the harness's potential, not proof of what happened outside it",
entry.artifact_id
),
);
}
VerifyCheck::warn(
"coverage",
"no coverage receipt in the sealed set: the package does not say what the harness could observe (sealed before 0.31.6, or minted without one)",
)
}
struct SealedAction {
action: String,
actor: String,
retry: Option<serde_json::Value>,
idempotency_key: Option<String>,
effect_signature: Option<String>,
effect_verified: bool,
}
fn retries_check(pkg_dir: &Path, receipt: &SessionReceipt) -> Option<VerifyCheck> {
use std::collections::{BTreeMap, BTreeSet};
let art_dir = pkg_dir.join(ARTIFACTS_DIR);
let mut actions: BTreeMap<String, SealedAction> = BTreeMap::new();
for entry in &receipt.artifacts {
let path = art_dir.join(format!("{}.json", sanitize_filename(&entry.artifact_id)));
let Ok(raw) = std::fs::read(&path) else {
continue;
};
let Ok(env) = crate::attestation::Envelope::from_json(&raw) else {
continue;
};
let Some(stmt) = env
.payload_bytes()
.ok()
.and_then(|b| serde_json::from_slice::<serde_json::Value>(&b).ok())
else {
continue;
};
let ty = stmt.get("type").and_then(|t| t.as_str()).unwrap_or("");
if !ty.contains("/action/") {
continue;
}
let effect = stmt.get("effect");
let effect_signature = effect
.and_then(|e| e.get("readback"))
.and_then(|v| v.as_str())
.or_else(|| {
effect
.and_then(|e| e.get("output_hash"))
.and_then(|v| v.as_str())
})
.or_else(|| {
stmt.get("meta")
.and_then(|m| m.get("output_digest"))
.and_then(|v| v.as_str())
})
.map(str::to_string);
let effect_verified = matches!(
effect
.and_then(|e| e.get("effect_confidence"))
.and_then(|v| v.as_str()),
Some("verified") | Some("partial")
);
actions.insert(
entry.artifact_id.clone(),
SealedAction {
action: stmt
.get("action")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
actor: stmt
.get("actor")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
idempotency_key: stmt
.get("idempotencyKey")
.or_else(|| stmt.get("idempotency_key"))
.or_else(|| stmt.get("retry").and_then(|r| r.get("idempotency_key")))
.and_then(|v| v.as_str())
.map(str::to_string),
retry: stmt.get("retry").cloned(),
effect_signature,
effect_verified,
},
);
}
let retries: Vec<(&String, &SealedAction)> =
actions.iter().filter(|(_, a)| a.retry.is_some()).collect();
if retries.is_empty() {
return None;
}
let mut problems: Vec<String> = Vec::new();
let mut chains: BTreeSet<String> = BTreeSet::new();
for (id, a) in &retries {
let r = a.retry.as_ref().unwrap();
let of = r.get("of").and_then(|v| v.as_str()).unwrap_or("");
let attempt = r.get("attempt").and_then(|v| v.as_u64()).unwrap_or(0);
let cause = r.get("cause").and_then(|v| v.as_str()).unwrap_or("unknown");
let key = r.get("idempotency_key").and_then(|v| v.as_str());
let Some(prev) = actions.get(of) else {
problems.push(format!(
"{id} (attempt {attempt}, {cause}) retries {of}, which is not in this package"
));
chains.insert(of.to_string());
continue;
};
let mut root = of.to_string();
let mut hops = 0;
while let Some(p) = actions.get(&root) {
match p
.retry
.as_ref()
.and_then(|x| x.get("of"))
.and_then(|v| v.as_str())
{
Some(next) if hops < 64 => {
root = next.to_string();
hops += 1;
}
_ => break,
}
}
chains.insert(root);
if prev.action != a.action || prev.actor != a.actor {
problems.push(format!(
"{id} retries {of} but is a different action or actor ({} by {} vs {} by {})",
a.action, a.actor, prev.action, prev.actor
));
}
let prev_attempt = prev
.retry
.as_ref()
.and_then(|x| x.get("attempt"))
.and_then(|v| v.as_u64())
.unwrap_or(1);
if attempt != prev_attempt + 1 {
problems.push(format!(
"{id} is attempt {attempt} but retries attempt {prev_attempt}"
));
}
let prev_key = prev.idempotency_key.as_deref();
if let (Some(k), Some(pk)) = (key, prev_key) {
if k != pk {
problems.push(format!(
"{id} retries {of} with a different idempotency key: the second attempt is not idempotent with the first"
));
}
}
if let (Some(cur), Some(before)) = (&a.effect_signature, &prev.effect_signature) {
if cur != before {
problems.push(format!(
"{id} and {of} both report an effect and they differ ({} vs {}): two mutations, not one recovery",
&cur[..cur.len().min(24)],
&before[..before.len().min(24)]
));
}
}
if cause == "timeout" && prev.effect_verified {
problems.push(format!(
"{id} retried {of} for a timeout, but {of} reports a verified effect: the first attempt landed"
));
}
}
let n = retries.len();
let c = chains.len();
if problems.is_empty() {
Some(VerifyCheck::pass(
"retries",
&format!(
"{n} retry attempt(s) across {c} chain(s): same action and actor, attempts count up, idempotency keys agree, and no two attempts report a distinct effect"
),
))
} else {
Some(VerifyCheck::warn(
"retries",
&format!(
"{n} retry attempt(s) across {c} chain(s); {}: {}",
problems.len(),
problems.join("; ")
),
))
}
}
fn judgements_check(pkg_dir: &Path, receipt: &SessionReceipt) -> Option<VerifyCheck> {
let art_dir = pkg_dir.join(ARTIFACTS_DIR);
let mut total = 0usize;
let mut judges: BTreeSet<String> = BTreeSet::new();
let mut flagged: Vec<String> = Vec::new();
let mut escalated: Vec<String> = Vec::new();
let mut effects: std::collections::BTreeMap<String, String> = Default::default();
let mut resolutions: std::collections::BTreeMap<String, (String, String, bool)> =
Default::default();
for entry in &receipt.artifacts {
let path = art_dir.join(format!("{}.json", sanitize_filename(&entry.artifact_id)));
let Ok(raw) = std::fs::read(&path) else {
continue;
};
let Ok(env) = crate::attestation::Envelope::from_json(&raw) else {
continue;
};
let Some(stmt) = env
.payload_bytes()
.ok()
.and_then(|b| serde_json::from_slice::<serde_json::Value>(&b).ok())
else {
continue;
};
if stmt.get("kind").and_then(|k| k.as_str()) != Some("judgement.resolution.v1") {
continue;
}
let Some(p) = stmt.get("payload") else {
continue;
};
let (Some(j), Some(by), Some(d)) = (
p.get("judgement").and_then(|v| v.as_str()),
p.get("by").and_then(|v| v.as_str()),
p.get("decision").and_then(|v| v.as_str()),
) else {
continue;
};
resolutions.insert(
j.to_string(),
(by.to_string(), d.to_string(), p.get("overrides").is_some()),
);
}
for entry in &receipt.artifacts {
let path = art_dir.join(format!("{}.json", sanitize_filename(&entry.artifact_id)));
let Ok(raw) = std::fs::read(&path) else {
continue;
};
let Ok(env) = crate::attestation::Envelope::from_json(&raw) else {
continue;
};
let Some(stmt) = env
.payload_bytes()
.ok()
.and_then(|b| serde_json::from_slice::<serde_json::Value>(&b).ok())
else {
continue;
};
if stmt.get("kind").and_then(|k| k.as_str()) != Some("judgement.v1") {
continue;
}
let Some(p) = stmt.get("payload") else {
continue;
};
total += 1;
if let Some(m) = p
.get("judge")
.and_then(|j| j.get("model"))
.and_then(|v| v.as_str())
{
judges.insert(m.to_string());
}
let outcome = p.get("outcome").and_then(|v| v.as_str()).unwrap_or("");
if let Some(e) = p.get("effect").and_then(|v| v.as_str()) {
effects.insert(entry.artifact_id.clone(), e.to_string());
}
if outcome == "escalated" {
escalated.push(entry.artifact_id.clone());
}
if outcome != "acted" {
continue;
}
let threshold = p
.get("threshold")
.and_then(|t| t.get("value"))
.and_then(|v| v.as_f64());
let applies_to = p
.get("threshold")
.and_then(|t| t.get("applies_to"))
.and_then(|v| v.as_str())
.unwrap_or("confidence");
let answer = p.get("answer");
let measured = match applies_to {
"noul" => answer.and_then(|a| a.get("noul")).and_then(|v| v.as_f64()),
_ => answer
.and_then(|a| a.get("confidence"))
.and_then(|v| v.as_f64())
.or_else(|| answer.and_then(|a| a.get("noul")).and_then(|v| v.as_f64())),
};
let effect = p.get("effect").and_then(|v| v.as_str());
match (threshold, measured) {
(None, _) => flagged.push(format!(
"{} acted with no threshold declared",
entry.artifact_id
)),
(Some(t), Some(m)) if applies_to == "noul" => {
let yes = m >= t;
let refusing = matches!(effect, Some("deny") | Some("ask"));
let allowing = matches!(effect, Some("allow") | Some("warn"));
if yes && allowing {
flagged.push(format!(
"{} acted to {} at noul {m:.3}, at or above its threshold {t:.3} (the answer was yes)",
entry.artifact_id,
effect.unwrap_or("")
));
} else if !yes && refusing {
flagged.push(format!(
"{} acted to {} at noul {m:.3}, below its threshold {t:.3} (the answer was no)",
entry.artifact_id,
effect.unwrap_or("")
));
} else if effect.is_none() && !yes {
flagged.push(format!(
"{} acted at noul {m:.3} below its threshold {t:.3} with no effect recorded",
entry.artifact_id
));
}
}
(Some(t), Some(m)) if m < t => flagged.push(format!(
"{} acted at {applies_to} {m:.3} below its threshold {t:.3}",
entry.artifact_id
)),
(Some(t), None) => flagged.push(format!(
"{} acted against a threshold of {t:.3} on {applies_to} but the answer carries no {applies_to}",
entry.artifact_id
)),
_ => {}
}
}
if total == 0 {
return None;
}
let who = judges.into_iter().collect::<Vec<_>>().join(", ");
let resolved: Vec<String> = escalated
.iter()
.filter_map(|j| {
resolutions.get(j).map(|(by, d, o)| {
format!(
"{j} {d} by {by}{}",
if *o { " (overriding the judge)" } else { "" }
)
})
})
.collect();
let open: Vec<&String> = escalated
.iter()
.filter(|j| !resolutions.contains_key(*j))
.collect();
let overrides: Vec<String> = resolutions
.iter()
.filter(|(j, (_, _, o))| *o && !escalated.contains(j))
.map(|(j, (by, d, _))| {
format!(
"{j} ({}) {d} by {by}",
effects.get(j).cloned().unwrap_or_default()
)
})
.collect();
let mut tail = String::new();
if !escalated.is_empty() {
tail.push_str(&format!(
"; {} escalated, {} resolved{}{}",
escalated.len(),
resolved.len(),
if resolved.is_empty() {
String::new()
} else {
format!(" ({})", resolved.join(", "))
},
if open.is_empty() {
String::new()
} else {
format!(
", {} OPEN with no signed resolution in this package: {}",
open.len(),
open.iter()
.map(|s| s.as_str())
.collect::<Vec<_>>()
.join(", ")
)
},
));
}
if !overrides.is_empty() {
tail.push_str(&format!(
"; {} judge decision(s) overridden by a signed resolution: {}",
overrides.len(),
overrides.join(", ")
));
}
if flagged.is_empty() && open.is_empty() {
Some(VerifyCheck::pass(
"judgements",
&format!(
"{total} judgement(s) by {who}; every one acted on met its declared threshold{tail}. The row reads the caller's record; it does not re-run a judge"
),
))
} else if flagged.is_empty() {
Some(VerifyCheck::warn(
"judgements",
&format!(
"{total} judgement(s) by {who}; every one acted on met its declared threshold{tail}"
),
))
} else {
Some(VerifyCheck::warn(
"judgements",
&format!(
"{total} judgement(s) by {who}; {} acted on outside its own bar: {}{tail}",
flagged.len(),
flagged.join("; ")
),
))
}
}
#[cfg(test)]
mod signed_scope_tests {
use super::signed_max_actions;
#[test]
fn max_actions_is_read_exactly_or_refused_never_truncated() {
let scope = |m: serde_json::Value| serde_json::json!({"scope": {"maxActions": m}});
assert_eq!(signed_max_actions(None), Ok(None));
assert_eq!(signed_max_actions(Some(&serde_json::json!({}))), Ok(None));
assert_eq!(
signed_max_actions(Some(&scope(serde_json::json!(1)))),
Ok(Some(1))
);
assert_eq!(
signed_max_actions(Some(&scope(serde_json::json!(u32::MAX)))),
Ok(Some(u32::MAX))
);
let big = u64::from(u32::MAX) + 2;
let err = signed_max_actions(Some(&scope(serde_json::json!(big)))).unwrap_err();
assert!(err.contains("beyond the supported range"), "{err}");
let err = signed_max_actions(Some(&scope(serde_json::json!("3")))).unwrap_err();
assert!(err.contains("not a whole number"), "{err}");
let err = signed_max_actions(Some(&scope(serde_json::json!(-1)))).unwrap_err();
assert!(err.contains("not a whole number"), "{err}");
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::session::event::*;
use crate::session::manifest::SessionManifest;
use crate::session::receipt::{ArtifactEntry, ReceiptComposer};
fn make_receipt() -> SessionReceipt {
let manifest = SessionManifest::new(
"ssn_pkg_test".into(),
"agent://test".into(),
"2026-04-05T08:00:00Z".into(),
1743843600000,
);
let mk = |seq: u64, inst: &str, et: EventType| -> SessionEvent {
SessionEvent {
session_id: "ssn_pkg_test".into(),
event_id: format!("evt_{:016x}", seq),
timestamp: format!("2026-04-05T08:{:02}:00Z", seq),
sequence_no: seq,
trace_id: "trace_1".into(),
span_id: format!("span_{seq}"),
parent_span_id: None,
agent_id: format!("agent://{inst}"),
agent_instance_id: inst.into(),
agent_name: inst.into(),
agent_role: None,
host_id: "host_1".into(),
tool_runtime_id: None,
event_type: et,
artifact_ref: None,
meta: None,
}
};
let events = vec![
mk(0, "root", EventType::SessionStarted),
mk(
1,
"root",
EventType::AgentStarted {
parent_agent_instance_id: None,
},
),
mk(
2,
"root",
EventType::AgentCalledTool {
tool_name: "read_file".into(),
tool_input_digest: None,
tool_output_digest: None,
duration_ms: Some(10),
},
),
mk(
3,
"root",
EventType::AgentCompleted {
termination_reason: None,
},
),
mk(
4,
"root",
EventType::SessionClosed {
summary: Some("Done".into()),
duration_ms: Some(60000),
},
),
];
let artifacts = vec![ArtifactEntry {
artifact_id: "art_001".into(),
payload_type: "action".into(),
digest: None,
signed_at: None,
unchained: false,
}];
ReceiptComposer::compose(&manifest, &events, artifacts)
}
#[test]
fn build_and_read_package() {
let receipt = make_receipt();
let tmp = std::env::temp_dir().join(format!("treeship-pkg-test-{}", rand::random::<u32>()));
let output = build_package(&receipt, &tmp).unwrap();
assert!(output.path.exists());
assert!(output.path.join("receipt.json").exists());
assert!(output.path.join("merkle.json").exists());
assert!(output.path.join("render.json").exists());
assert!(output.path.join("preview.html").exists());
assert!(output.receipt_digest.starts_with("sha256:"));
assert!(output.file_count >= 4);
let read_back = read_package(&output.path).unwrap();
assert_eq!(read_back.session.id, "ssn_pkg_test");
assert_eq!(read_back.type_, RECEIPT_TYPE);
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn verify_valid_package() {
let receipt = make_receipt();
let tmp =
std::env::temp_dir().join(format!("treeship-pkg-verify-{}", rand::random::<u32>()));
let output = build_package(&receipt, &tmp).unwrap();
let checks = verify_package_structural(&output.path).unwrap();
let fails: Vec<_> = checks
.iter()
.filter(|c| c.status == VerifyStatus::Fail)
.collect();
assert!(fails.is_empty(), "unexpected failures: {fails:?}");
let passes: Vec<_> = checks
.iter()
.filter(|c| c.status == VerifyStatus::Pass)
.collect();
assert!(
passes.len() >= 5,
"expected at least 5 pass checks, got {}",
passes.len()
);
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn verify_warns_when_reconcile_degraded() {
let mut receipt = make_receipt();
receipt.proofs.reconcile_degraded = true;
let tmp =
std::env::temp_dir().join(format!("treeship-pkg-degraded-{}", rand::random::<u32>()));
let output = build_package(&receipt, &tmp).unwrap();
let checks = verify_package_structural(&output.path).unwrap();
let warned = checks
.iter()
.any(|c| c.name == "reconcile_degraded" && c.status == VerifyStatus::Warn);
assert!(warned, "expected a reconcile_degraded WARN, got {checks:?}");
let fails: Vec<_> = checks
.iter()
.filter(|c| c.status == VerifyStatus::Fail)
.collect();
assert!(fails.is_empty(), "must not hard-fail: {fails:?}");
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn verify_no_degraded_warn_when_clean() {
let receipt = make_receipt();
let tmp =
std::env::temp_dir().join(format!("treeship-pkg-clean-{}", rand::random::<u32>()));
let output = build_package(&receipt, &tmp).unwrap();
let checks = verify_package_structural(&output.path).unwrap();
assert!(
!checks.iter().any(|c| c.name == "reconcile_degraded"),
"clean receipt must not emit a reconcile_degraded check"
);
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn verify_detects_missing_receipt() {
let tmp =
std::env::temp_dir().join(format!("treeship-pkg-empty-{}", rand::random::<u32>()));
std::fs::create_dir_all(&tmp).unwrap();
let err = read_package(&tmp);
assert!(err.is_err());
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn preview_html_renders_approval_evidence_from_the_bundle() {
use crate::attestation::sign::sign;
use crate::attestation::Ed25519Signer;
use crate::statements::ApprovalScope;
use crate::statements::TYPE_APPROVAL_USE;
let receipt = make_receipt();
assert_eq!(preview_approvals_json(None), serde_json::Value::Null);
assert_eq!(
preview_approvals_json(Some(&ApprovalsBundle::default())),
serde_json::Value::Null,
"an empty bundle is the same as none"
);
let signer = Ed25519Signer::generate("key_test_preview").unwrap();
let mut grant = ApprovalStatement::new("human://operator", "nonce-preview-0001");
grant.description = Some("apply change chg-0001: 3% clearance".into());
grant.scope = Some(ApprovalScope {
max_actions: Some(1),
valid_until: None,
allowed_actors: vec!["agent://merchant".into()],
allowed_actions: vec!["commerce.tool.apply_change.intent".into()],
allowed_subjects: vec!["change://chg-0001".into()],
extra: None,
});
let signed = sign("application/vnd.treeship.approval.v1+json", &grant, &signer).unwrap();
let grant_id = signed.artifact_id.to_string();
let grant_bytes = serde_json::to_vec(&signed.envelope).unwrap();
let use_record = ApprovalUse {
type_: TYPE_APPROVAL_USE.into(),
use_id: "use_preview_0001".into(),
grant_id: grant_id.clone(),
grant_digest: signed.digest.clone(),
nonce_digest: "sha256:00".into(),
actor: "agent://merchant".into(),
action: "commerce.tool.apply_change.intent".into(),
subject: "change://chg-0001".into(),
session_id: Some("ssn_pkg_test".into()),
action_artifact_id: Some("art_apply_intent".into()),
receipt_digest: None,
use_number: 1,
max_uses: Some(1),
idempotency_key: None,
created_at: "2026-09-07T10:45:49Z".into(),
expires_at: None,
previous_record_digest: String::new(),
record_digest: String::new(),
signature: None,
signature_alg: None,
signing_key_id: None,
};
let bundle = ApprovalsBundle {
grants: vec![
(grant_id.clone(), grant_bytes),
("art_garbage".into(), b"not json".to_vec()),
],
uses: vec![use_record],
..Default::default()
};
let summary = preview_approvals_json(Some(&bundle));
let grants = summary["grants"].as_array().unwrap();
assert_eq!(grants.len(), 2);
assert_eq!(grants[0]["parsed"], true);
assert_eq!(grants[0]["approver"], "human://operator");
assert_eq!(grants[0]["scope"]["max_uses"], 1);
assert_eq!(
grants[0]["scope"]["allowed_subjects"][0],
"change://chg-0001"
);
assert_eq!(grants[1]["parsed"], false);
assert_eq!(grants[1]["grant_id"], "art_garbage");
let uses = summary["uses"].as_array().unwrap();
assert_eq!(uses[0]["use_number"], 1);
assert_eq!(uses[0]["action_artifact_id"], "art_apply_intent");
let html = render_preview_html_with_approvals(&receipt, Some(&bundle));
assert!(html.contains("id=\"approvals-data\""));
assert!(html.contains("\"approver\":\"human://operator\""));
assert!(html.contains("\"subject\":\"change://chg-0001\""));
assert!(
!html.contains("__APPROVALS_JSON__"),
"placeholder must be substituted"
);
let plain = render_preview_html(&receipt);
assert!(plain.contains("type=\"application/json\">null</script>"));
}
#[test]
fn preview_html_contains_session_info() {
let receipt = make_receipt();
let html = render_preview_html(&receipt);
assert!(html.contains("ssn_pkg_test"));
assert!(html.contains("treeship.dev"));
assert!(html.contains("Timeline"));
assert!(
html.contains("'__RECEIPT'+'_JSON__'"),
"JS placeholder check was clobbered by the receipt substitution",
);
assert!(
!html.contains("application/json\">__RECEIPT_JSON__</script>"),
"data slot was not substituted with the receipt JSON",
);
assert_eq!(
html.matches("__RECEIPT_JSON__").count(),
0,
"no raw placeholder token should remain after substitution",
);
}
}