1use std::collections::BTreeSet;
13use std::path::{Path, PathBuf};
14
15use crate::statements::ApprovalStatement;
16use serde::{Deserialize, Serialize};
17use sha2::{Digest, Sha256};
18
19use super::receipt::{ArtifactEntry, SessionReceipt, RECEIPT_TYPE};
20use crate::statements::{
21 approval_revocation_record_digest, approval_use_record_digest,
22 journal_checkpoint_record_digest, ApprovalRevocation, ApprovalUse, JournalCheckpoint,
23 ReplayCheck, ReplayCheckLevel,
24};
25
26#[derive(Debug)]
28pub enum PackageError {
29 Io(std::io::Error),
30 Json(serde_json::Error),
31 InvalidPackage(String),
32}
33
34impl std::fmt::Display for PackageError {
35 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
36 match self {
37 Self::Io(e) => write!(f, "package io: {e}"),
38 Self::Json(e) => write!(f, "package json: {e}"),
39 Self::InvalidPackage(msg) => write!(f, "invalid package: {msg}"),
40 }
41 }
42}
43
44impl std::error::Error for PackageError {}
45impl From<std::io::Error> for PackageError {
46 fn from(e: std::io::Error) -> Self {
47 Self::Io(e)
48 }
49}
50impl From<serde_json::Error> for PackageError {
51 fn from(e: serde_json::Error) -> Self {
52 Self::Json(e)
53 }
54}
55
56const RECEIPT_FILE: &str = "receipt.json";
58const MERKLE_FILE: &str = "merkle.json";
59const RENDER_FILE: &str = "render.json";
60const ARTIFACTS_DIR: &str = "artifacts";
61const PROOFS_DIR: &str = "proofs";
62const PREVIEW_FILE: &str = "preview.html";
63
64const APPROVALS_DIR: &str = "approvals";
77const APPROVALS_GRANTS: &str = "approvals/grants";
78const APPROVALS_USES: &str = "approvals/uses";
79const APPROVALS_CHECKPOINTS: &str = "approvals/checkpoints";
80const APPROVALS_INDEX_FILE: &str = "approvals/index.json";
81
82#[derive(Debug, Clone, Default)]
92pub struct ApprovalsBundle {
93 pub grants: Vec<(String, Vec<u8>)>,
98 pub uses: Vec<ApprovalUse>,
102 pub checkpoints: Vec<JournalCheckpoint>,
105 pub revocations: Vec<ApprovalRevocation>,
110
111 pub action_envelopes: Vec<(String, Vec<u8>)>,
119
120 pub sealed_envelopes: Vec<(String, Vec<u8>)>,
126 pub signer_keys: Vec<(String, String)>,
131}
132
133#[derive(Debug, Clone, Serialize, Deserialize, Default)]
135pub struct PackageKeys {
136 pub schema: String,
137 pub keys: std::collections::BTreeMap<String, String>,
139}
140
141pub const KEYS_FILE: &str = "keys.json";
142pub const RECORD_FILE: &str = "record.json";
146
147pub const OWN_KEY_LABEL: &str = "this ship's own key";
151pub const PACKAGE_KEYS_SCHEMA: &str = "treeship/package-keys/v1";
152
153#[derive(Debug, Clone, Serialize, Deserialize)]
157pub struct ApprovalsIndex {
158 #[serde(rename = "type")]
160 pub type_: String,
161 pub schema_version: u32,
162 pub grants: Vec<String>,
165 pub uses: Vec<String>,
167 pub checkpoints: Vec<String>,
168 pub revocations: Vec<String>,
169}
170
171impl ApprovalsIndex {
172 pub fn type_string() -> &'static str {
173 "treeship/approvals-index/v1"
174 }
175}
176
177pub struct PackageOutput {
179 pub path: PathBuf,
181 pub receipt_digest: String,
183 pub merkle_root: Option<String>,
185 pub file_count: usize,
187}
188
189pub fn build_package(
199 receipt: &SessionReceipt,
200 output_dir: &Path,
201) -> Result<PackageOutput, PackageError> {
202 build_package_with_approvals(receipt, output_dir, None)
203}
204
205pub fn build_package_with_approvals(
209 receipt: &SessionReceipt,
210 output_dir: &Path,
211 bundle: Option<&ApprovalsBundle>,
212) -> Result<PackageOutput, PackageError> {
213 let session_id = &receipt.session.id;
214 let pkg_dir = output_dir.join(format!("{session_id}.treeship"));
215
216 std::fs::create_dir_all(&pkg_dir)?;
217 std::fs::create_dir_all(pkg_dir.join(ARTIFACTS_DIR))?;
218 std::fs::create_dir_all(pkg_dir.join(PROOFS_DIR))?;
219
220 let mut file_count = 0usize;
221
222 let receipt_bytes = serde_json::to_vec_pretty(receipt)?;
224 std::fs::write(pkg_dir.join(RECEIPT_FILE), &receipt_bytes)?;
225 file_count += 1;
226
227 let receipt_hash = Sha256::digest(&receipt_bytes);
228 let receipt_digest = format!("sha256:{}", hex::encode(receipt_hash));
229
230 let merkle_bytes = serde_json::to_vec_pretty(&receipt.merkle)?;
232 std::fs::write(pkg_dir.join(MERKLE_FILE), &merkle_bytes)?;
233 file_count += 1;
234
235 let render_bytes = serde_json::to_vec_pretty(&receipt.render)?;
237 std::fs::write(pkg_dir.join(RENDER_FILE), &render_bytes)?;
238 file_count += 1;
239
240 for proof_entry in &receipt.merkle.inclusion_proofs {
242 let proof_bytes = serde_json::to_vec_pretty(proof_entry)?;
243 let filename = format!("{}.proof.json", proof_entry.artifact_id);
244 std::fs::write(pkg_dir.join(PROOFS_DIR).join(filename), &proof_bytes)?;
245 file_count += 1;
246 }
247
248 if receipt.render.generate_preview {
250 let preview = render_preview_html_with_approvals(receipt, bundle);
251 std::fs::write(pkg_dir.join(PREVIEW_FILE), preview.as_bytes())?;
252 file_count += 1;
253 }
254
255 if let Some(b) = bundle {
260 if !b.sealed_envelopes.is_empty() {
263 std::fs::create_dir_all(pkg_dir.join(ARTIFACTS_DIR))?;
264 for (artifact_id, envelope_bytes) in &b.sealed_envelopes {
265 let safe = sanitize_filename(artifact_id);
266 let path = pkg_dir.join(ARTIFACTS_DIR).join(format!("{safe}.json"));
267 if !path.exists() {
268 std::fs::write(path, envelope_bytes)?;
269 file_count += 1;
270 }
271 }
272 }
273 if !b.signer_keys.is_empty() {
274 let keys = PackageKeys {
275 schema: PACKAGE_KEYS_SCHEMA.into(),
276 keys: b.signer_keys.iter().cloned().collect(),
277 };
278 std::fs::write(pkg_dir.join(KEYS_FILE), serde_json::to_vec_pretty(&keys)?)?;
279 file_count += 1;
280 }
281 if !b.grants.is_empty()
282 || !b.uses.is_empty()
283 || !b.checkpoints.is_empty()
284 || !b.revocations.is_empty()
285 || !b.action_envelopes.is_empty()
286 {
287 std::fs::create_dir_all(pkg_dir.join(APPROVALS_GRANTS))?;
288 std::fs::create_dir_all(pkg_dir.join(APPROVALS_USES))?;
289 std::fs::create_dir_all(pkg_dir.join(APPROVALS_CHECKPOINTS))?;
290 std::fs::create_dir_all(pkg_dir.join(ARTIFACTS_DIR))?;
296 for (artifact_id, envelope_bytes) in &b.action_envelopes {
297 let safe = sanitize_filename(artifact_id);
298 std::fs::write(
299 pkg_dir.join(ARTIFACTS_DIR).join(format!("{safe}.json")),
300 envelope_bytes,
301 )?;
302 file_count += 1;
303 }
304
305 let mut grant_ids = Vec::with_capacity(b.grants.len());
306 for (grant_id, envelope_bytes) in &b.grants {
307 let safe = sanitize_filename(grant_id);
308 std::fs::write(
309 pkg_dir.join(APPROVALS_GRANTS).join(format!("{safe}.json")),
310 envelope_bytes,
311 )?;
312 grant_ids.push(grant_id.clone());
313 file_count += 1;
314 }
315
316 let mut use_ids = Vec::with_capacity(b.uses.len());
317 for u in &b.uses {
318 let safe = sanitize_filename(&u.use_id);
319 let bytes = serde_json::to_vec_pretty(u)?;
320 std::fs::write(
321 pkg_dir.join(APPROVALS_USES).join(format!("{safe}.json")),
322 &bytes,
323 )?;
324 use_ids.push(u.use_id.clone());
325 file_count += 1;
326 }
327
328 let mut checkpoint_ids = Vec::with_capacity(b.checkpoints.len());
329 for cp in &b.checkpoints {
330 let safe = sanitize_filename(&cp.checkpoint_id);
331 let bytes = serde_json::to_vec_pretty(cp)?;
332 std::fs::write(
333 pkg_dir
334 .join(APPROVALS_CHECKPOINTS)
335 .join(format!("{safe}.json")),
336 &bytes,
337 )?;
338 checkpoint_ids.push(cp.checkpoint_id.clone());
339 file_count += 1;
340 }
341
342 let mut revocation_ids = Vec::with_capacity(b.revocations.len());
343 for rev in &b.revocations {
344 let safe = sanitize_filename(&rev.revocation_id);
345 let bytes = serde_json::to_vec_pretty(rev)?;
346 std::fs::write(
347 pkg_dir
348 .join(APPROVALS_DIR)
349 .join(format!("revocations-{safe}.json")),
350 &bytes,
351 )?;
352 revocation_ids.push(rev.revocation_id.clone());
353 file_count += 1;
354 }
355
356 let index = ApprovalsIndex {
357 type_: ApprovalsIndex::type_string().into(),
358 schema_version: 1,
359 grants: grant_ids,
360 uses: use_ids,
361 checkpoints: checkpoint_ids,
362 revocations: revocation_ids,
363 };
364 let index_bytes = serde_json::to_vec_pretty(&index)?;
365 std::fs::write(pkg_dir.join(APPROVALS_INDEX_FILE), &index_bytes)?;
366 file_count += 1;
367 }
368 }
369
370 Ok(PackageOutput {
371 path: pkg_dir,
372 receipt_digest,
373 merkle_root: receipt.merkle.root.clone(),
374 file_count,
375 })
376}
377
378fn sanitize_filename(s: &str) -> String {
382 s.chars()
383 .map(|c| {
384 if c.is_ascii_alphanumeric() || c == '-' || c == '.' || c == '_' {
385 c
386 } else {
387 '_'
388 }
389 })
390 .collect()
391}
392
393pub fn read_approvals_bundle(pkg_dir: &Path) -> Result<ApprovalsBundle, PackageError> {
403 let approvals_dir = pkg_dir.join(APPROVALS_DIR);
404 if !approvals_dir.is_dir() {
405 return Ok(ApprovalsBundle::default());
406 }
407
408 let mut bundle = ApprovalsBundle::default();
409
410 let grants_dir = pkg_dir.join(APPROVALS_GRANTS);
413 if grants_dir.is_dir() {
414 for entry in std::fs::read_dir(&grants_dir)? {
415 let entry = entry?;
416 let path = entry.path();
417 if path.extension().and_then(|s| s.to_str()) != Some("json") {
418 continue;
419 }
420 let id = path
421 .file_stem()
422 .and_then(|s| s.to_str())
423 .unwrap_or("")
424 .to_string();
425 let bytes = std::fs::read(&path)?;
426 bundle.grants.push((id, bytes));
427 }
428 }
429
430 let uses_dir = pkg_dir.join(APPROVALS_USES);
431 if uses_dir.is_dir() {
432 for entry in std::fs::read_dir(&uses_dir)? {
433 let entry = entry?;
434 let path = entry.path();
435 if path.extension().and_then(|s| s.to_str()) != Some("json") {
436 continue;
437 }
438 let bytes = std::fs::read(&path)?;
439 let u: ApprovalUse = serde_json::from_slice(&bytes)?;
440 bundle.uses.push(u);
441 }
442 }
443
444 let cps_dir = pkg_dir.join(APPROVALS_CHECKPOINTS);
445 if cps_dir.is_dir() {
446 for entry in std::fs::read_dir(&cps_dir)? {
447 let entry = entry?;
448 let path = entry.path();
449 if path.extension().and_then(|s| s.to_str()) != Some("json") {
450 continue;
451 }
452 let bytes = std::fs::read(&path)?;
453 let cp: JournalCheckpoint = serde_json::from_slice(&bytes)?;
454 bundle.checkpoints.push(cp);
455 }
456 }
457
458 let arts_dir = pkg_dir.join(ARTIFACTS_DIR);
465 if arts_dir.is_dir() {
466 for entry in std::fs::read_dir(&arts_dir)? {
467 let entry = entry?;
468 let path = entry.path();
469 if path.extension().and_then(|s| s.to_str()) != Some("json") {
470 continue;
471 }
472 let id = path
473 .file_stem()
474 .and_then(|s| s.to_str())
475 .unwrap_or("")
476 .to_string();
477 let bytes = std::fs::read(&path)?;
478 bundle.action_envelopes.push((id, bytes));
479 }
480 }
481
482 Ok(bundle)
483}
484
485pub fn read_package(pkg_dir: &Path) -> Result<SessionReceipt, PackageError> {
487 let receipt_path = pkg_dir.join(RECEIPT_FILE);
488 if !receipt_path.exists() {
489 return Err(PackageError::InvalidPackage(format!(
490 "missing {RECEIPT_FILE} in {}",
491 pkg_dir.display()
492 )));
493 }
494 let bytes = std::fs::read(&receipt_path)?;
495 let receipt: SessionReceipt = serde_json::from_slice(&bytes)?;
496
497 if receipt.type_ != RECEIPT_TYPE {
498 return Err(PackageError::InvalidPackage(format!(
499 "unexpected type: {} (expected {RECEIPT_TYPE})",
500 receipt.type_
501 )));
502 }
503
504 Ok(receipt)
505}
506
507pub fn verify_package(pkg_dir: &Path) -> Result<Vec<VerifyCheck>, PackageError> {
525 let trust = match crate::trust::TrustRootStore::open_default_or_empty() {
526 Ok(t) => t,
527 Err(e) => {
528 return Ok(vec![VerifyCheck::fail(
532 "trust-root",
533 &format!("trust store unreadable: {e}"),
534 )]);
535 }
536 };
537 verify_package_with_trust(pkg_dir, &trust)
538}
539
540pub fn verify_package_with_trust(
544 pkg_dir: &Path,
545 trust: &crate::trust::TrustRootStore,
546) -> Result<Vec<VerifyCheck>, PackageError> {
547 verify_package_with_options(pkg_dir, trust, false)
548}
549
550pub fn verify_package_structural(pkg_dir: &Path) -> Result<Vec<VerifyCheck>, PackageError> {
557 let trust = crate::trust::TrustRootStore::open_default_or_empty()
558 .unwrap_or_else(|_| crate::trust::TrustRootStore::empty());
559 verify_package_with_options(pkg_dir, &trust, true)
560}
561
562pub fn verify_package_with_options(
563 pkg_dir: &Path,
564 trust: &crate::trust::TrustRootStore,
565 structural_only: bool,
566) -> Result<Vec<VerifyCheck>, PackageError> {
567 let mut checks = Vec::new();
568
569 let receipt = match read_package(pkg_dir) {
571 Ok(r) => {
572 checks.push(VerifyCheck::pass(
573 "receipt.json",
574 "Parses as valid Session Receipt",
575 ));
576 r
577 }
578 Err(e) => {
579 checks.push(VerifyCheck::fail(
580 "receipt.json",
581 &format!("Failed to parse: {e}"),
582 ));
583 return Ok(checks);
584 }
585 };
586
587 if receipt.type_ == RECEIPT_TYPE {
589 checks.push(VerifyCheck::pass("type", "Correct receipt type"));
590 } else {
591 checks.push(VerifyCheck::fail(
592 "type",
593 &format!("Expected {RECEIPT_TYPE}, got {}", receipt.type_),
594 ));
595 }
596
597 let receipt_path = pkg_dir.join(RECEIPT_FILE);
612 let on_disk = std::fs::read(&receipt_path)?;
613 let re_serialized = serde_json::to_vec_pretty(&receipt)?;
614 if on_disk == re_serialized {
615 checks.push(VerifyCheck::pass(
616 "determinism",
617 "receipt.json round-trips identically (structural, NOT a signature)",
618 ));
619 } else {
620 checks.push(VerifyCheck::warn(
622 "determinism",
623 "receipt.json does not byte-match after re-serialization",
624 ));
625 }
626
627 checks.push(coverage_check(pkg_dir, &receipt));
642
643 if let Some(tu) = receipt.tool_usage.as_ref() {
647 if !tu.network_declared.is_empty() {
648 let total = receipt.side_effects.network_connections.len();
649 if tu.network_off_scope.is_empty() {
650 checks.push(VerifyCheck::pass(
651 "network_scope",
652 &format!(
653 "{total} recorded connection(s), all within the declared scope [{}]",
654 tu.network_declared.join(", ")
655 ),
656 ));
657 } else {
658 checks.push(VerifyCheck::warn(
659 "network_scope",
660 &format!(
661 "{} destination(s) outside the declared scope [{}]: {}",
662 tu.network_off_scope.len(),
663 tu.network_declared.join(", "),
664 tu.network_off_scope.join(", ")
665 ),
666 ));
667 }
668 }
669 }
670
671 if let Some(row) = retries_check(pkg_dir, &receipt) {
678 checks.push(row);
679 }
680
681 if let Some(row) = judgements_check(pkg_dir, &receipt) {
685 checks.push(row);
686 }
687
688 if !receipt.artifacts.is_empty() {
690 let version = receipt.merkle.merkle_version;
697 let mut tree = match crate::merkle::MerkleTree::with_version(version) {
698 Ok(t) => t,
699 Err(e) => {
700 checks.push(VerifyCheck::fail(
701 "merkle_root",
702 &format!("receipt declared unknown merkle_version: {e}"),
703 ));
704 return Ok(finish_package_checks(checks, &receipt));
707 }
708 };
709 for art in &receipt.artifacts {
710 tree.append(&art.artifact_id);
711 }
712 let root_bytes = tree.root();
713 let recomputed_root = root_bytes.map(|r| format!("mroot_{}", hex::encode(r)));
714 let root_hex = root_bytes.map(hex::encode).unwrap_or_default();
715
716 if recomputed_root == receipt.merkle.root {
717 checks.push(VerifyCheck::pass(
718 "merkle_root",
719 "Merkle root matches recomputed value",
720 ));
721 } else {
722 checks.push(VerifyCheck::fail(
723 "merkle_root",
724 &format!(
725 "Mismatch: on-disk {:?} vs recomputed {:?}",
726 receipt.merkle.root, recomputed_root
727 ),
728 ));
729 }
730
731 for proof_entry in &receipt.merkle.inclusion_proofs {
736 if proof_entry.proof.merkle_version != version {
737 checks.push(VerifyCheck::fail(
738 &format!("inclusion:{}", proof_entry.artifact_id),
739 &format!(
740 "proof merkle_version {} != receipt section v{}",
741 proof_entry.proof.merkle_version, version,
742 ),
743 ));
744 continue;
745 }
746 let verified = crate::merkle::MerkleTree::verify_proof(
747 version,
748 &root_hex,
749 &proof_entry.artifact_id,
750 &proof_entry.proof,
751 );
752 if verified {
753 checks.push(VerifyCheck::pass(
754 &format!("inclusion:{}", proof_entry.artifact_id),
755 "Inclusion proof valid",
756 ));
757 } else {
758 checks.push(VerifyCheck::fail(
759 &format!("inclusion:{}", proof_entry.artifact_id),
760 "Inclusion proof failed verification",
761 ));
762 }
763 }
764 } else {
765 checks.push(VerifyCheck::warn("merkle_root", "No artifacts to verify"));
766 }
767
768 verify_sealed_envelopes(pkg_dir, &receipt, trust, structural_only, &mut checks);
771 let body_bound = verify_receipt_binding(pkg_dir, &receipt, structural_only, &mut checks);
772 if body_bound {
773 checks.push(VerifyCheck::pass(
774 "receipt_body_binding",
775 "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",
776 ));
777 } else {
778 checks.push(VerifyCheck::warn(
779 "receipt_body_binding",
780 "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).",
781 ));
782 }
783 verify_session_window(pkg_dir, &receipt, &mut checks);
784
785 if receipt.merkle.leaf_count == receipt.artifacts.len() {
787 checks.push(VerifyCheck::pass(
788 "leaf_count",
789 "Leaf count matches artifact count",
790 ));
791 } else {
792 checks.push(VerifyCheck::fail(
793 "leaf_count",
794 &format!(
795 "leaf_count {} != artifact count {}",
796 receipt.merkle.leaf_count,
797 receipt.artifacts.len()
798 ),
799 ));
800 }
801
802 let ordered = receipt.timeline.windows(2).all(|w| {
804 (&w[0].timestamp, w[0].sequence_no, &w[0].event_id)
805 <= (&w[1].timestamp, w[1].sequence_no, &w[1].event_id)
806 });
807 if ordered {
808 checks.push(VerifyCheck::pass(
809 "timeline_order",
810 "Timeline is correctly ordered",
811 ));
812 } else {
813 checks.push(VerifyCheck::fail(
814 "timeline_order",
815 "Timeline entries are not in deterministic order",
816 ));
817 }
818
819 if receipt.proofs.event_log_skipped > 0 {
827 checks.push(VerifyCheck::warn(
828 "event_log_completeness",
829 &format!(
830 "{} event(s) skipped during close (malformed lines in events.jsonl). \
831 Receipt is cryptographically valid but does not represent the full event stream. \
832 Inspect close-time stderr or the events.jsonl directly to investigate.",
833 receipt.proofs.event_log_skipped,
834 ),
835 ));
836 }
837
838 if receipt.proofs.reconcile_untracked_truncated > 0 {
839 checks.push(VerifyCheck::warn(
840 "reconcile_completeness",
841 &format!(
842 "untracked git reconcile exceeded cap {} (saw at least {}). \
843 Per-file synthetic events were skipped and the receipt is bounded, not complete for untracked files.",
844 receipt.proofs.reconcile_untracked_cap,
845 receipt.proofs.reconcile_untracked_truncated,
846 ),
847 ));
848 }
849
850 if receipt.proofs.reconcile_degraded {
856 checks.push(VerifyCheck::warn(
857 "reconcile_degraded",
858 "the git reconcile backstop was UNAVAILABLE at session close although git worked at start \
859 (.git removed, corrupt index, or git not on PATH). Files changed outside a captured \
860 AgentWroteFile event may be MISSING from this receipt's file ledger — treat the \
861 \"Files changed\" list as incomplete.",
862 ));
863 }
864
865 let bundle = read_approvals_bundle(pkg_dir).unwrap_or_default();
877 add_approval_evidence_checks(&mut checks, &bundle, trust);
878
879 Ok(checks)
880}
881
882const SIGNER_KINDS: &[crate::trust::TrustRootKind] = &[
888 crate::trust::TrustRootKind::CertIssuer,
889 crate::trust::TrustRootKind::AgentCert,
890 crate::trust::TrustRootKind::SessionHost,
891];
892
893fn read_package_keys(pkg_dir: &Path) -> Option<PackageKeys> {
894 let raw = std::fs::read(pkg_dir.join(KEYS_FILE)).ok()?;
895 serde_json::from_slice(&raw).ok()
896}
897
898fn package_verifying_keys(
901 pkg_dir: &Path,
902) -> std::collections::BTreeMap<String, ed25519_dalek::VerifyingKey> {
903 let mut keys = std::collections::BTreeMap::new();
904 if let Some(pk) = read_package_keys(pkg_dir) {
905 for (id, encoded) in pk.keys {
906 if let Ok(vk) = crate::trust::decode_ed25519_pubkey(&encoded) {
907 keys.insert(id, vk);
908 }
909 }
910 }
911 keys
912}
913
914fn verify_receipt_binding(
925 pkg_dir: &Path,
926 receipt: &SessionReceipt,
927 structural_only: bool,
928 checks: &mut Vec<VerifyCheck>,
929) -> bool {
930 use sha2::{Digest, Sha256};
931 let path = pkg_dir.join(RECORD_FILE);
932 let raw = match std::fs::read(&path) {
933 Ok(b) => b,
934 Err(_) => {
935 checks.push(VerifyCheck::warn(
936 "receipt_binding",
937 "the package carries no close record (built before 0.31.4), so the sealed set is not under a signature: an artifact could be added to the list and the tree recomputed without any per-artifact row failing",
938 ));
939 return false;
940 }
941 };
942 if structural_only {
943 checks.push(VerifyCheck::warn(
944 "receipt_binding",
945 "close record present but not checked under --structural",
946 ));
947 return false;
948 }
949 let envelope = match crate::attestation::Envelope::from_json(&raw) {
950 Ok(e) => e,
951 Err(e) => {
952 checks.push(VerifyCheck::fail(
953 "receipt_binding",
954 &format!("record.json does not parse as a DSSE envelope: {e}"),
955 ));
956 return false;
957 }
958 };
959 let Some(sig) = envelope.signatures.first() else {
960 checks.push(VerifyCheck::fail(
961 "receipt_binding",
962 "record.json carries no signature",
963 ));
964 return false;
965 };
966 let keys = package_verifying_keys(pkg_dir);
967 let Some(vk) = keys.get(&sig.keyid) else {
968 checks.push(VerifyCheck::fail(
969 "receipt_binding",
970 &format!(
971 "record.json is signed by {}, a key the package does not carry",
972 sig.keyid
973 ),
974 ));
975 return false;
976 };
977 if let Err(e) = crate::attestation::verify_with_key(&envelope, &sig.keyid, *vk) {
978 checks.push(VerifyCheck::fail(
979 "receipt_binding",
980 &format!("record.json signature invalid for key {}: {e}", sig.keyid),
981 ));
982 return false;
983 }
984 let payload: serde_json::Value = match envelope
985 .payload_bytes()
986 .ok()
987 .and_then(|b| serde_json::from_slice(&b).ok())
988 {
989 Some(v) => v,
990 None => {
991 checks.push(VerifyCheck::fail(
992 "receipt_binding",
993 "record.json payload is not JSON",
994 ));
995 return false;
996 }
997 };
998 let signed_digest = payload
999 .get("payload")
1000 .and_then(|p| p.get("receipt_digest"))
1001 .and_then(|d| d.as_str())
1002 .unwrap_or("");
1003 let signed_session = payload
1004 .get("payload")
1005 .and_then(|p| p.get("session_id"))
1006 .and_then(|d| d.as_str())
1007 .unwrap_or("");
1008 let receipt_bytes = std::fs::read(pkg_dir.join(RECEIPT_FILE)).unwrap_or_default();
1009 let actual = format!("sha256:{}", hex::encode(Sha256::digest(&receipt_bytes)));
1010 if signed_session != receipt.session.id {
1011 checks.push(VerifyCheck::fail(
1012 "receipt_binding",
1013 &format!(
1014 "the close record names session {} but this receipt is {}",
1015 signed_session, receipt.session.id
1016 ),
1017 ));
1018 } else if signed_digest != actual {
1019 checks.push(VerifyCheck::fail(
1020 "receipt_binding",
1021 &format!(
1022 "the producer signed receipt digest {} at close but receipt.json now digests to {}: the sealed set was rewritten after it was signed",
1023 signed_digest, actual
1024 ),
1025 ));
1026 } else {
1027 checks.push(VerifyCheck::pass(
1028 "receipt_binding",
1029 &format!(
1030 "close record signed by {} binds receipt.json ({}) and names this session",
1031 sig.keyid, actual
1032 ),
1033 ));
1034 return true;
1035 }
1036 false
1037}
1038
1039fn verify_session_window(pkg_dir: &Path, receipt: &SessionReceipt, checks: &mut Vec<VerifyCheck>) {
1044 use crate::statements::invitation::parse_rfc3339_to_unix;
1045 const SKEW: u64 = 120;
1046 let Some(started) = parse_rfc3339_to_unix(&receipt.session.started_at) else {
1047 return;
1048 };
1049 let ended = receipt
1050 .session
1051 .ended_at
1052 .as_deref()
1053 .and_then(parse_rfc3339_to_unix);
1054 let art_dir = pkg_dir.join(ARTIFACTS_DIR);
1055 let mut outside: Vec<String> = Vec::new();
1056 let mut seen = 0usize;
1057 for entry in &receipt.artifacts {
1058 let path = art_dir.join(format!("{}.json", sanitize_filename(&entry.artifact_id)));
1059 let Ok(raw) = std::fs::read(&path) else {
1060 continue;
1061 };
1062 let Ok(env) = crate::attestation::Envelope::from_json(&raw) else {
1063 continue;
1064 };
1065 let Some(ts) = env
1066 .payload_bytes()
1067 .ok()
1068 .and_then(|b| serde_json::from_slice::<serde_json::Value>(&b).ok())
1069 .and_then(|v| {
1070 v.get("timestamp")
1071 .and_then(|t| t.as_str())
1072 .map(str::to_string)
1073 })
1074 else {
1075 continue;
1076 };
1077 let Some(t) = parse_rfc3339_to_unix(&ts) else {
1078 continue;
1079 };
1080 seen += 1;
1081 let before = t + SKEW < started;
1082 let after = ended.map(|e| t > e + SKEW).unwrap_or(false);
1083 if before || after {
1084 outside.push(format!("{} ({})", entry.artifact_id, ts));
1085 }
1086 }
1087 if seen == 0 {
1088 return;
1089 }
1090 if outside.is_empty() {
1091 checks.push(VerifyCheck::pass(
1092 "session_window",
1093 &format!("{seen} sealed artifact(s) were signed inside the session's window"),
1094 ));
1095 } else {
1096 checks.push(VerifyCheck::warn(
1097 "session_window",
1098 &format!(
1099 "{} sealed artifact(s) were signed outside the session's window ({} to {}): {}",
1100 outside.len(),
1101 receipt.session.started_at,
1102 receipt
1103 .session
1104 .ended_at
1105 .clone()
1106 .unwrap_or_else(|| "open".into()),
1107 outside.join(", ")
1108 ),
1109 ));
1110 }
1111}
1112
1113fn verify_sealed_envelopes(
1124 pkg_dir: &Path,
1125 receipt: &SessionReceipt,
1126 trust: &crate::trust::TrustRootStore,
1127 structural_only: bool,
1128 checks: &mut Vec<VerifyCheck>,
1129) {
1130 use std::collections::{BTreeMap, BTreeSet};
1131
1132 if receipt.artifacts.is_empty() {
1133 return;
1134 }
1135 let art_dir = pkg_dir.join(ARTIFACTS_DIR);
1136 let any_envelope = receipt.artifacts.iter().any(|a| {
1137 art_dir
1138 .join(format!("{}.json", sanitize_filename(&a.artifact_id)))
1139 .exists()
1140 });
1141 if !any_envelope {
1142 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)";
1143 checks.push(if structural_only {
1144 VerifyCheck::warn("envelopes", detail)
1145 } else {
1146 VerifyCheck::fail("envelopes", detail)
1147 });
1148 return;
1149 }
1150
1151 let mut keys: BTreeMap<String, ed25519_dalek::VerifyingKey> = BTreeMap::new();
1154 match read_package_keys(pkg_dir) {
1155 Some(pk) => {
1156 for (id, encoded) in pk.keys {
1157 match crate::trust::decode_ed25519_pubkey(&encoded) {
1158 Ok(vk) => {
1159 keys.insert(id, vk);
1160 }
1161 Err(e) => checks.push(VerifyCheck::fail(
1162 "keys.json",
1163 &format!("key {id} is not a valid ed25519 public key: {e}"),
1164 )),
1165 }
1166 }
1167 }
1168 None => checks.push(VerifyCheck::fail(
1169 "keys.json",
1170 "package has artifact envelopes but no keys.json naming the signing keys",
1171 )),
1172 }
1173
1174 let mut parents: Vec<(String, Option<String>)> = Vec::new();
1175 let mut signers: BTreeSet<String> = BTreeSet::new();
1176 let mut ok_count = 0usize;
1177 for entry in &receipt.artifacts {
1178 let id = &entry.artifact_id;
1179 let name = format!("signature:{id}");
1180 let path = art_dir.join(format!("{}.json", sanitize_filename(id)));
1181 let raw = match std::fs::read(&path) {
1182 Ok(b) => b,
1183 Err(_) => {
1184 checks.push(VerifyCheck::fail(
1185 &name,
1186 "sealed in the Merkle tree but its signed envelope is not in the package",
1187 ));
1188 parents.push((id.clone(), None));
1189 continue;
1190 }
1191 };
1192 let envelope = match crate::attestation::Envelope::from_json(&raw) {
1193 Ok(e) => e,
1194 Err(e) => {
1195 checks.push(VerifyCheck::fail(
1196 &name,
1197 &format!("envelope does not parse: {e}"),
1198 ));
1199 parents.push((id.clone(), None));
1200 continue;
1201 }
1202 };
1203 let Some(sig) = envelope.signatures.first() else {
1204 checks.push(VerifyCheck::fail(&name, "envelope carries no signature"));
1205 parents.push((id.clone(), None));
1206 continue;
1207 };
1208 let Some(vk) = keys.get(&sig.keyid) else {
1209 checks.push(VerifyCheck::fail(
1210 &name,
1211 &format!("signed by {}, a key the package does not carry", sig.keyid),
1212 ));
1213 parents.push((id.clone(), None));
1214 continue;
1215 };
1216 match crate::attestation::verify_with_key(&envelope, &sig.keyid, *vk) {
1217 Ok(res) => {
1218 if res.artifact_id != *id {
1219 checks.push(VerifyCheck::fail(
1220 &name,
1221 &format!(
1222 "the signed bytes re-derive to {}, not the sealed id",
1223 res.artifact_id
1224 ),
1225 ));
1226 } else if entry
1227 .digest
1228 .as_deref()
1229 .map(|d| d != res.digest)
1230 .unwrap_or(false)
1231 {
1232 checks.push(VerifyCheck::fail(
1233 &name,
1234 &format!(
1235 "receipt lists digest {} but the signed bytes digest to {}",
1236 entry.digest.clone().unwrap_or_default(),
1237 res.digest
1238 ),
1239 ));
1240 } else {
1241 ok_count += 1;
1242 signers.insert(sig.keyid.clone());
1243 checks.push(VerifyCheck::pass(&name, &format!("Ed25519 signature by {} verifies; id and digest re-derived from the signed bytes", sig.keyid)));
1244 }
1245 }
1246 Err(e) => checks.push(VerifyCheck::fail(
1247 &name,
1248 &format!("invalid signature for key {}: {e}", sig.keyid),
1249 )),
1250 }
1251 let parent = envelope
1252 .payload_bytes()
1253 .ok()
1254 .and_then(|b| serde_json::from_slice::<serde_json::Value>(&b).ok())
1255 .and_then(|v| {
1256 v.get("parentId")
1257 .and_then(|p| p.as_str())
1258 .map(str::to_string)
1259 });
1260 parents.push((id.clone(), parent));
1261 }
1262
1263 let chained: Vec<(usize, &ArtifactEntry)> = receipt
1267 .artifacts
1268 .iter()
1269 .enumerate()
1270 .filter(|(_, a)| !a.unchained)
1271 .collect();
1272 let mut broken: Vec<String> = Vec::new();
1273 for w in chained.windows(2) {
1274 let (i_prev, prev) = w[0];
1275 let (i_cur, cur) = w[1];
1276 let _ = (i_prev, i_cur);
1277 let signed_parent = parents
1278 .iter()
1279 .find(|(id, _)| *id == cur.artifact_id)
1280 .and_then(|(_, p)| p.clone());
1281 match signed_parent {
1282 Some(p) if p == prev.artifact_id => {}
1283 Some(p) => broken.push(format!(
1284 "{} names parent {} but follows {}",
1285 cur.artifact_id, p, prev.artifact_id
1286 )),
1287 None => broken.push(format!("{} has no readable parentId", cur.artifact_id)),
1288 }
1289 }
1290 if chained.len() >= 2 {
1291 if broken.is_empty() {
1292 checks.push(VerifyCheck::pass("chain_linkage", &format!("{} chained artifacts each name the previous one as parent, inside the signature", chained.len())));
1293 } else {
1294 checks.push(VerifyCheck::fail("chain_linkage", &broken.join("; ")));
1295 }
1296 }
1297 let unchained: Vec<&str> = receipt
1298 .artifacts
1299 .iter()
1300 .filter(|a| a.unchained)
1301 .map(|a| a.artifact_id.as_str())
1302 .collect();
1303 if !unchained.is_empty() {
1304 checks.push(VerifyCheck::warn("chain_completeness", &format!("{} sealed artifact(s) were signed during the session but never chained onto it ({}); signed and sealed, but their order relative to the chain is the signer's claim only", unchained.len(), unchained.join(", "))));
1305 }
1306
1307 if ok_count > 0 {
1309 let unpinned: Vec<String> = signers
1310 .iter()
1311 .filter(|k| {
1312 let vk = keys.get(*k).expect("signer seen in keys");
1313 !SIGNER_KINDS.iter().any(|kind| trust.contains(vk, *kind))
1314 })
1315 .cloned()
1316 .collect();
1317 if unpinned.is_empty() {
1318 let own: Vec<&str> = signers
1322 .iter()
1323 .filter(|k| {
1324 trust
1325 .roots()
1326 .iter()
1327 .any(|r| &r.key_id == *k && r.label == OWN_KEY_LABEL)
1328 })
1329 .map(|k| k.as_str())
1330 .collect();
1331 let detail = if own.len() == signers.len() {
1332 format!(
1333 "all {} signing key(s) are this ship's own ({}); a stranger pins them before this row passes on their machine",
1334 signers.len(),
1335 own.join(", ")
1336 )
1337 } else if own.is_empty() {
1338 format!(
1339 "all {} signing key(s) are pinned trust roots",
1340 signers.len()
1341 )
1342 } else {
1343 format!(
1344 "{} signing key(s): {} pinned trust root(s), {} this ship's own ({})",
1345 signers.len(),
1346 signers.len() - own.len(),
1347 own.len(),
1348 own.join(", ")
1349 )
1350 };
1351 checks.push(VerifyCheck::pass("signer_trust", &detail));
1352 } else {
1353 let pins: Vec<String> = unpinned
1354 .iter()
1355 .map(|k| {
1356 let vk = keys.get(k).expect("key");
1357 format!(
1360 "treeship trust add {k} {} --kind cert_issuer --yes",
1361 crate::trust::encode_ed25519_pubkey(vk)
1362 )
1363 })
1364 .collect();
1365 checks.push(VerifyCheck::warn("signer_trust", &format!("signature(s) verify for the key(s) the package names, but {} of them are not pinned trust roots here: {}. Pin what you have decided to trust: {}", unpinned.len(), unpinned.join(", "), pins.join("; "))));
1366 }
1367 }
1368}
1369
1370fn finish_package_checks(
1371 mut checks: Vec<VerifyCheck>,
1372 receipt: &SessionReceipt,
1373) -> Vec<VerifyCheck> {
1374 if receipt.merkle.leaf_count == receipt.artifacts.len() {
1375 checks.push(VerifyCheck::pass(
1376 "leaf_count",
1377 "Leaf count matches artifact count",
1378 ));
1379 } else {
1380 checks.push(VerifyCheck::fail(
1381 "leaf_count",
1382 &format!(
1383 "leaf_count {} != artifact count {}",
1384 receipt.merkle.leaf_count,
1385 receipt.artifacts.len(),
1386 ),
1387 ));
1388 }
1389
1390 let ordered = receipt.timeline.windows(2).all(|w| {
1391 (&w[0].timestamp, w[0].sequence_no, &w[0].event_id)
1392 <= (&w[1].timestamp, w[1].sequence_no, &w[1].event_id)
1393 });
1394 if ordered {
1395 checks.push(VerifyCheck::pass(
1396 "timeline_order",
1397 "Timeline is correctly ordered",
1398 ));
1399 } else {
1400 checks.push(VerifyCheck::fail(
1401 "timeline_order",
1402 "Timeline entries are not in deterministic order",
1403 ));
1404 }
1405
1406 checks
1407}
1408
1409pub(crate) fn add_approval_evidence_checks(
1420 checks: &mut Vec<VerifyCheck>,
1421 bundle: &ApprovalsBundle,
1422 trust: &crate::trust::TrustRootStore,
1423) {
1424 if bundle.uses.is_empty() && bundle.checkpoints.is_empty() {
1425 return;
1429 }
1430
1431 use std::collections::HashMap;
1440 let mut by_nonce: HashMap<(String, String), Vec<&ApprovalUse>> = HashMap::new();
1441 let mut by_use_id: HashMap<&str, Vec<&ApprovalUse>> = HashMap::new();
1442 for u in &bundle.uses {
1443 by_nonce
1444 .entry((u.grant_id.clone(), u.nonce_digest.clone()))
1445 .or_default()
1446 .push(u);
1447 by_use_id.entry(&u.use_id).or_default().push(u);
1448 }
1449 let over_max: Vec<((String, String), Vec<&ApprovalUse>, u32)> = by_nonce
1450 .iter()
1451 .filter_map(|(key, uses)| {
1452 let max = uses.iter().filter_map(|u| u.max_uses).next()?;
1453 if (uses.len() as u32) > max {
1454 Some((key.clone(), uses.to_vec(), max))
1455 } else {
1456 None
1457 }
1458 })
1459 .collect();
1460 let dup_use_ids: Vec<(&&str, &Vec<&ApprovalUse>)> =
1461 by_use_id.iter().filter(|(_, v)| v.len() > 1).collect();
1462
1463 if over_max.is_empty() && dup_use_ids.is_empty() {
1464 checks.push(VerifyCheck::pass(
1465 "replay-package-local",
1466 &format!(
1467 "no duplicate approval use inside package ({} uses scanned)",
1468 bundle.uses.len()
1469 ),
1470 ));
1471 } else {
1472 let mut detail = String::from("package-local replay violation:");
1473 for ((grant_id, _nd), uses, max) in &over_max {
1474 detail.push_str(&format!(
1475 " grant {grant_id} consumed {} times in this package (max_uses={max});",
1476 uses.len(),
1477 ));
1478 }
1479 for (uid, uses) in &dup_use_ids {
1480 detail.push_str(&format!(" use_id {uid} appears {} times;", uses.len()));
1481 }
1482 checks.push(VerifyCheck::fail("replay-package-local", &detail));
1483 }
1484
1485 if !bundle.checkpoints.is_empty() {
1490 let mut tampered = Vec::new();
1491 for cp in &bundle.checkpoints {
1492 let recomputed = journal_checkpoint_record_digest(cp);
1493 if recomputed != cp.record_digest {
1494 tampered.push((
1495 cp.checkpoint_id.clone(),
1496 cp.record_digest.clone(),
1497 recomputed,
1498 ));
1499 }
1500 }
1501 if tampered.is_empty() {
1502 checks.push(VerifyCheck::pass(
1503 "replay-included-checkpoint",
1504 &format!(
1505 "{} included journal checkpoint(s) verify offline",
1506 bundle.checkpoints.len()
1507 ),
1508 ));
1509 } else {
1510 let detail = tampered
1511 .iter()
1512 .map(|(id, expected, actual)| {
1513 format!("checkpoint {id} tampered (stored {expected}, recomputed {actual})")
1514 })
1515 .collect::<Vec<_>>()
1516 .join("; ");
1517 checks.push(VerifyCheck::fail("replay-included-checkpoint", &detail));
1518 }
1519 }
1520
1521 let mut tampered_uses = Vec::new();
1531 for u in &bundle.uses {
1532 let recomputed = approval_use_record_digest(u);
1533 if recomputed != u.record_digest {
1534 tampered_uses.push((u.use_id.clone(), u.record_digest.clone(), recomputed));
1535 }
1536 }
1537 if !bundle.uses.is_empty() {
1538 if tampered_uses.is_empty() {
1539 checks.push(VerifyCheck::pass(
1540 "approval-use-record-digest",
1541 &format!("{} use record(s) recompute identically", bundle.uses.len()),
1542 ));
1543 } else {
1544 let detail = tampered_uses
1545 .iter()
1546 .map(|(id, expected, actual)| {
1547 format!("use {id} tampered (stored {expected}, recomputed {actual})")
1548 })
1549 .collect::<Vec<_>>()
1550 .join("; ");
1551 checks.push(VerifyCheck::fail("approval-use-record-digest", &detail));
1552 }
1553 }
1554
1555 if !bundle.uses.is_empty() {
1573 use crate::attestation::envelope::Envelope;
1574 use crate::attestation::{artifact_id_from_pae, pae};
1575 use crate::statements::{nonce_digest, ApprovalStatement};
1576 let mut grant_nonce_digest: std::collections::HashMap<String, String> =
1577 std::collections::HashMap::new();
1578 let mut tampered_grants: Vec<String> = Vec::new();
1579 for (grant_id, env_bytes) in &bundle.grants {
1580 let env = match Envelope::from_json(env_bytes) {
1581 Ok(e) => e,
1582 Err(_) => {
1583 tampered_grants.push(format!("grant {grant_id} envelope unparseable"));
1584 continue;
1585 }
1586 };
1587 let derived = match env.payload_bytes() {
1592 Ok(p) => artifact_id_from_pae(&pae(&env.payload_type, &p)),
1593 Err(_) => {
1594 tampered_grants.push(format!("grant {grant_id} envelope payload undecodable"));
1595 continue;
1596 }
1597 };
1598 if &derived != grant_id {
1599 tampered_grants.push(format!(
1600 "grant {grant_id} envelope content derives to {derived} -- envelope substituted or tampered",
1601 ));
1602 continue;
1603 }
1604 let approval: ApprovalStatement = match env.unmarshal_statement() {
1605 Ok(a) => a,
1606 Err(_) => {
1607 tampered_grants
1608 .push(format!("grant {grant_id} payload not an ApprovalStatement"));
1609 continue;
1610 }
1611 };
1612 grant_nonce_digest.insert(grant_id.clone(), nonce_digest(&approval.nonce));
1613 }
1614 let mut mismatches: Vec<String> = Vec::new();
1615 let mut missing_grants: Vec<String> = Vec::new();
1616 for u in &bundle.uses {
1617 match grant_nonce_digest.get(&u.grant_id) {
1618 Some(expected) => {
1619 if expected != &u.nonce_digest {
1620 mismatches.push(format!(
1621 "use {} claims nonce_digest {} but grant {} signed nonce hashes to {}",
1622 u.use_id, u.nonce_digest, u.grant_id, expected,
1623 ));
1624 }
1625 }
1626 None => {
1627 missing_grants.push(format!(
1628 "use {} references grant {} but no usable grant envelope is in the package",
1629 u.use_id, u.grant_id,
1630 ));
1631 }
1632 }
1633 }
1634 if mismatches.is_empty() && missing_grants.is_empty() && tampered_grants.is_empty() {
1635 checks.push(VerifyCheck::pass(
1636 "approval-use-nonce-binding",
1637 &format!(
1638 "{} use record(s) bind to content-addressed grant signed nonces",
1639 bundle.uses.len(),
1640 ),
1641 ));
1642 } else {
1643 let mut parts: Vec<String> = Vec::new();
1644 if !tampered_grants.is_empty() {
1645 parts.push(tampered_grants.join("; "));
1646 }
1647 if !mismatches.is_empty() {
1648 parts.push(mismatches.join("; "));
1649 }
1650 if !missing_grants.is_empty() {
1651 parts.push(missing_grants.join("; "));
1652 }
1653 checks.push(VerifyCheck::fail(
1654 "approval-use-nonce-binding",
1655 &parts.join("; "),
1656 ));
1657 }
1658 }
1659
1660 if !bundle.uses.is_empty() {
1675 use crate::attestation::envelope::Envelope;
1676 use crate::attestation::{artifact_id_from_pae, pae};
1677 use crate::statements::{nonce_digest, ActionStatement};
1678 if bundle.action_envelopes.is_empty() {
1679 checks.push(VerifyCheck::warn(
1680 "approval-use-action-binding",
1681 "no action envelopes embedded -- action↔use binding not asserted by package (pre-v0.9.10)",
1682 ));
1683 } else {
1684 let use_ids: std::collections::HashSet<&str> =
1685 bundle.uses.iter().map(|u| u.use_id.as_str()).collect();
1686 let mut violations: Vec<String> = Vec::new();
1687 let mut bound_count = 0usize;
1688 for (artifact_id, env_bytes) in &bundle.action_envelopes {
1689 let env = match Envelope::from_json(env_bytes) {
1690 Ok(e) => e,
1691 Err(_) => {
1692 violations.push(format!("action {artifact_id} envelope unparseable"));
1693 continue;
1694 }
1695 };
1696 let derived = match env.payload_bytes() {
1704 Ok(p) => artifact_id_from_pae(&pae(&env.payload_type, &p)),
1705 Err(_) => {
1706 violations
1707 .push(format!("action {artifact_id} envelope payload undecodable"));
1708 continue;
1709 }
1710 };
1711 if &derived != artifact_id {
1712 violations.push(format!(
1713 "action {artifact_id} envelope content derives to {derived} -- envelope substituted or tampered",
1714 ));
1715 continue;
1716 }
1717 let action: ActionStatement = match env.unmarshal_statement() {
1718 Ok(a) => a,
1719 Err(_) => {
1720 violations.push(format!("action {artifact_id} not an ActionStatement"));
1721 continue;
1722 }
1723 };
1724 let raw_nonce = match action.approval_nonce.as_deref() {
1725 Some(n) => n,
1726 None => continue,
1727 };
1728 let claimed_use_id = action
1729 .meta
1730 .as_ref()
1731 .and_then(|m| m.get("approval_use_id"))
1732 .and_then(|v| v.as_str());
1733 let Some(claimed_use_id) = claimed_use_id else {
1734 violations.push(format!(
1735 "action {artifact_id} consumed an approval but its meta has no approval_use_id"
1736 ));
1737 continue;
1738 };
1739 if !use_ids.contains(claimed_use_id) {
1740 violations.push(format!(
1741 "action {artifact_id} claims approval_use_id={} but no such use is embedded",
1742 claimed_use_id,
1743 ));
1744 continue;
1745 }
1746 let expected = nonce_digest(raw_nonce);
1747 let matched_use = bundle.uses.iter().find(|u| u.use_id == claimed_use_id);
1748 if let Some(u) = matched_use {
1749 if u.nonce_digest != expected {
1750 violations.push(format!(
1751 "action {artifact_id} approval_nonce hashes to {} but use {} stores nonce_digest {}",
1752 expected, claimed_use_id, u.nonce_digest,
1753 ));
1754 continue;
1755 }
1756 }
1757 bound_count += 1;
1758 }
1759 if violations.is_empty() {
1760 checks.push(VerifyCheck::pass(
1761 "approval-use-action-binding",
1762 &format!(
1763 "{bound_count} consuming action(s) bind cleanly to content-addressed envelope(s)",
1764 ),
1765 ));
1766 } else {
1767 checks.push(VerifyCheck::fail(
1768 "approval-use-action-binding",
1769 &violations.join("; "),
1770 ));
1771 }
1772 }
1773 }
1774
1775 if !bundle.uses.is_empty() || !bundle.checkpoints.is_empty() {
1802 use std::collections::{HashMap, HashSet};
1803 struct Node<'a> {
1806 label: String,
1807 digest: &'a str,
1808 prev: &'a str,
1809 }
1810 let mut nodes: Vec<Node> = Vec::new();
1811 for u in &bundle.uses {
1812 nodes.push(Node {
1813 label: format!("use {}", u.use_id),
1814 digest: u.record_digest.as_str(),
1815 prev: u.previous_record_digest.as_str(),
1816 });
1817 }
1818 for cp in &bundle.checkpoints {
1819 nodes.push(Node {
1820 label: format!("checkpoint {}", cp.checkpoint_id),
1821 digest: cp.record_digest.as_str(),
1822 prev: cp.previous_record_digest.as_str(),
1823 });
1824 }
1825
1826 let owned: HashSet<&str> = std::iter::once("")
1827 .chain(nodes.iter().map(|n| n.digest))
1828 .collect();
1829
1830 let mut violations: Vec<String> = Vec::new();
1831 for n in &nodes {
1833 if !owned.contains(n.prev) {
1834 violations.push(format!(
1835 "{} previous_record_digest {} not anchored in package",
1836 n.label, n.prev,
1837 ));
1838 }
1839 }
1840 let genesis: Vec<&Node> = nodes.iter().filter(|n| n.prev.is_empty()).collect();
1842 if genesis.len() > 1 {
1843 violations.push(format!(
1844 "{} records claim previous_record_digest='' (genesis): {}",
1845 genesis.len(),
1846 genesis
1847 .iter()
1848 .map(|n| n.label.clone())
1849 .collect::<Vec<_>>()
1850 .join(", "),
1851 ));
1852 }
1853 let mut by_prev: HashMap<&str, Vec<&Node>> = HashMap::new();
1855 for n in &nodes {
1856 by_prev.entry(n.prev).or_default().push(n);
1857 }
1858 for (prev, group) in &by_prev {
1859 if group.len() > 1 && !prev.is_empty() {
1860 violations.push(format!(
1861 "fork: {} records share previous_record_digest {}: {}",
1862 group.len(),
1863 prev,
1864 group
1865 .iter()
1866 .map(|n| n.label.clone())
1867 .collect::<Vec<_>>()
1868 .join(", "),
1869 ));
1870 }
1871 }
1872
1873 if violations.is_empty() {
1876 let by_digest: HashMap<&str, &Node> = nodes.iter().map(|n| (n.digest, n)).collect();
1877 let next_of: HashMap<&str, &Node> = nodes
1878 .iter()
1879 .filter(|n| !n.prev.is_empty())
1880 .map(|n| (n.prev, n))
1881 .collect();
1882 let start = genesis.first().copied();
1883 let mut visited: HashSet<&str> = HashSet::new();
1884 let mut current = start;
1885 while let Some(node) = current {
1886 if !visited.insert(node.digest) {
1887 violations.push(format!(
1888 "cycle detected at {} (record_digest {})",
1889 node.label, node.digest,
1890 ));
1891 break;
1892 }
1893 current = next_of.get(node.digest).copied();
1894 }
1895 if violations.is_empty() && visited.len() != nodes.len() {
1897 let unreached: Vec<String> = nodes
1898 .iter()
1899 .filter(|n| !visited.contains(n.digest))
1900 .map(|n| n.label.clone())
1901 .collect();
1902 if !unreached.is_empty() {
1903 violations.push(format!(
1904 "disconnected subchain: {} record(s) not reachable from genesis: {}",
1905 unreached.len(),
1906 unreached.join(", "),
1907 ));
1908 }
1909 }
1910 let _ = by_digest; }
1912
1913 if violations.is_empty() {
1914 checks.push(VerifyCheck::pass(
1915 "approval-use-chain-continuity",
1916 &format!(
1917 "{} record(s) form a single connected linked list from one genesis with no cycles or forks",
1918 nodes.len(),
1919 ),
1920 ));
1921 } else {
1922 checks.push(VerifyCheck::fail(
1923 "approval-use-chain-continuity",
1924 &violations.join("; "),
1925 ));
1926 }
1927 }
1928
1929 let hub_checkpoints: Vec<&JournalCheckpoint> = bundle
1943 .checkpoints
1944 .iter()
1945 .filter(|cp| cp.checkpoint_kind == crate::statements::CheckpointKind::HubOrg)
1946 .collect();
1947 if !hub_checkpoints.is_empty() {
1948 let mut all_ok = true;
1949 let mut details: Vec<String> = Vec::new();
1950 let mut have_valid_signature = false;
1951 let mut security_fatal = false;
1960
1961 for cp in &hub_checkpoints {
1962 match crate::statements::verify_hub_checkpoint_signature(cp, trust) {
1963 crate::statements::HubCheckpointVerification::Valid => {
1964 have_valid_signature = true;
1965 let covered: std::collections::HashSet<&String> =
1970 cp.covered_use_ids.iter().collect();
1971 let missing: Vec<String> = bundle
1972 .uses
1973 .iter()
1974 .filter(|u| !covered.contains(&u.use_id))
1975 .map(|u| u.use_id.clone())
1976 .collect();
1977 if missing.is_empty() {
1978 details.push(format!(
1979 "{} signed by {} verifies; covers {} use(s)",
1980 cp.checkpoint_id,
1981 cp.hub_id,
1982 cp.covered_use_ids.len(),
1983 ));
1984 } else {
1985 all_ok = false;
1986 details.push(format!(
1987 "{} verifies but does not cover {} use(s): {}",
1988 cp.checkpoint_id,
1989 missing.len(),
1990 missing.join(", "),
1991 ));
1992 }
1993 }
1994 crate::statements::HubCheckpointVerification::MissingFields(field) => {
1995 all_ok = false;
1996 details.push(format!(
1997 "{} declares kind=hub-org but field `{}` is missing",
1998 cp.checkpoint_id, field,
1999 ));
2000 }
2001 crate::statements::HubCheckpointVerification::Tampered => {
2002 all_ok = false;
2003 security_fatal = true;
2004 details.push(format!(
2005 "{} hub signature failed verification (tampered or wrong key)",
2006 cp.checkpoint_id,
2007 ));
2008 }
2009 crate::statements::HubCheckpointVerification::NotHubKind => {
2010 all_ok = false;
2014 security_fatal = true;
2015 details.push(format!(
2016 "{} kind toggled out of hub-org during verify",
2017 cp.checkpoint_id,
2018 ));
2019 }
2020 crate::statements::HubCheckpointVerification::UntrustedIssuer => {
2021 all_ok = false;
2022 security_fatal = true;
2023 details.push(format!(
2024 "{} hub_public_key is not a trusted root (configure via `treeship trust add`)",
2025 cp.checkpoint_id,
2026 ));
2027 }
2028 }
2029 }
2030 if all_ok && have_valid_signature {
2031 checks.push(VerifyCheck::pass("replay-hub-org", &details.join("; ")));
2032 } else if security_fatal {
2033 checks.push(VerifyCheck::fail("replay-hub-org", &details.join("; ")));
2037 } else {
2038 checks.push(VerifyCheck::warn("replay-hub-org", &details.join("; ")));
2043 }
2044 }
2045 let _ = ReplayCheckLevel::HubOrg;
2049 let _ = approval_revocation_record_digest as fn(&ApprovalRevocation) -> String;
2050 let _ = ReplayCheck::not_performed;
2051}
2052
2053#[derive(Debug, Clone)]
2055pub struct VerifyCheck {
2056 pub name: String,
2057 pub status: VerifyStatus,
2058 pub detail: String,
2059}
2060
2061#[derive(Debug, Clone, PartialEq, Eq)]
2063pub enum VerifyStatus {
2064 Pass,
2065 Fail,
2066 Warn,
2067}
2068
2069impl VerifyCheck {
2070 pub fn pass(name: &str, detail: &str) -> Self {
2071 Self {
2072 name: name.into(),
2073 status: VerifyStatus::Pass,
2074 detail: detail.into(),
2075 }
2076 }
2077 pub fn fail(name: &str, detail: &str) -> Self {
2078 Self {
2079 name: name.into(),
2080 status: VerifyStatus::Fail,
2081 detail: detail.into(),
2082 }
2083 }
2084 pub fn warn(name: &str, detail: &str) -> Self {
2085 Self {
2086 name: name.into(),
2087 status: VerifyStatus::Warn,
2088 detail: detail.into(),
2089 }
2090 }
2091}
2092
2093impl VerifyCheck {
2094 pub fn passed(&self) -> bool {
2095 self.status == VerifyStatus::Pass
2096 }
2097}
2098
2099const PREVIEW_TEMPLATE: &str = include_str!("preview_template.html");
2102
2103const FRAUNCES_WOFF2: &[u8] = include_bytes!("../../assets/fonts/fraunces-latin-var.woff2");
2114
2115fn fraunces_data_uri() -> String {
2119 use base64::engine::general_purpose::STANDARD;
2120 use base64::Engine;
2121 format!("data:font/woff2;base64,{}", STANDARD.encode(FRAUNCES_WOFF2))
2122}
2123
2124pub fn render_preview_html(receipt: &SessionReceipt) -> String {
2131 render_preview_html_with_approvals(receipt, None)
2132}
2133
2134pub fn preview_approvals_json(bundle: Option<&ApprovalsBundle>) -> serde_json::Value {
2146 let Some(b) = bundle else {
2147 return serde_json::Value::Null;
2148 };
2149 if b.grants.is_empty() && b.uses.is_empty() {
2150 return serde_json::Value::Null;
2151 }
2152 let grants: Vec<serde_json::Value> = b
2153 .grants
2154 .iter()
2155 .map(|(grant_id, bytes)| {
2156 let parsed = crate::attestation::Envelope::from_json(bytes)
2157 .ok()
2158 .and_then(|env| env.unmarshal_statement::<ApprovalStatement>().ok());
2159 match parsed {
2160 Some(st) => serde_json::json!({
2161 "grant_id": grant_id,
2162 "parsed": true,
2163 "approver": st.approver,
2164 "description": st.description,
2165 "timestamp": st.timestamp,
2166 "expires_at": st.expires_at,
2167 "scope": st.scope.as_ref().map(|sc| serde_json::json!({
2168 "allowed_actors": sc.allowed_actors,
2169 "allowed_actions": sc.allowed_actions,
2170 "allowed_subjects": sc.allowed_subjects,
2171 "max_uses": sc.max_actions,
2172 "valid_until": sc.valid_until,
2173 })),
2174 }),
2175 None => serde_json::json!({ "grant_id": grant_id, "parsed": false }),
2176 }
2177 })
2178 .collect();
2179 let uses: Vec<serde_json::Value> = b
2180 .uses
2181 .iter()
2182 .map(|u| serde_json::to_value(u).unwrap_or(serde_json::Value::Null))
2183 .collect();
2184 serde_json::json!({ "grants": grants, "uses": uses })
2185}
2186
2187pub fn render_preview_html_with_approvals(
2189 receipt: &SessionReceipt,
2190 bundle: Option<&ApprovalsBundle>,
2191) -> String {
2192 let approvals_json = preview_approvals_json(bundle).to_string();
2193 let safe_approvals = approvals_json.replace('<', r"\u003c");
2194 let receipt_json = serde_json::to_string_pretty(receipt).unwrap_or_else(|_| "{}".to_string());
2195 let safe_json = receipt_json.replace('<', r"\u003c");
2203
2204 PREVIEW_TEMPLATE
2211 .replacen("__RECEIPT_JSON__", &safe_json, 1)
2212 .replacen("__APPROVALS_JSON__", &safe_approvals, 1)
2213 .replace("__FONT_FRAUNCES__", &fraunces_data_uri())
2214}
2215
2216fn coverage_check(pkg_dir: &Path, receipt: &SessionReceipt) -> VerifyCheck {
2219 let art_dir = pkg_dir.join(ARTIFACTS_DIR);
2220 for entry in &receipt.artifacts {
2221 let path = art_dir.join(format!("{}.json", sanitize_filename(&entry.artifact_id)));
2222 let Ok(raw) = std::fs::read(&path) else {
2223 continue;
2224 };
2225 let Ok(env) = crate::attestation::Envelope::from_json(&raw) else {
2226 continue;
2227 };
2228 let Some(stmt) = env
2229 .payload_bytes()
2230 .ok()
2231 .and_then(|b| serde_json::from_slice::<serde_json::Value>(&b).ok())
2232 else {
2233 continue;
2234 };
2235 if stmt.get("kind").and_then(|k| k.as_str()) != Some("coverage.v1") {
2236 continue;
2237 }
2238 let Some(p) = stmt.get("payload") else {
2239 continue;
2240 };
2241 let level = p
2242 .get("declared_level")
2243 .and_then(|v| v.as_str())
2244 .unwrap_or("?");
2245 let harnesses: Vec<String> = p
2246 .get("harnesses")
2247 .and_then(|h| h.as_array())
2248 .map(|arr| {
2249 arr.iter()
2250 .map(|h| {
2251 let id = h.get("harness_id").and_then(|v| v.as_str()).unwrap_or("?");
2252 let modes: Vec<&str> = h
2253 .get("connection_modes")
2254 .and_then(|m| m.as_array())
2255 .map(|m| m.iter().filter_map(|x| x.as_str()).collect())
2256 .unwrap_or_default();
2257 if modes.is_empty() {
2258 id.to_string()
2259 } else {
2260 format!("{id} via {}", modes.join("+"))
2261 }
2262 })
2263 .collect()
2264 })
2265 .unwrap_or_default();
2266 let events = p
2267 .get("observed")
2268 .and_then(|o| o.get("events"))
2269 .and_then(|v| v.as_u64())
2270 .unwrap_or(0);
2271 let types = p
2272 .get("observed")
2273 .and_then(|o| o.get("event_types"))
2274 .and_then(|t| t.as_object())
2275 .map(|m| m.len())
2276 .unwrap_or(0);
2277 let gaps = p
2278 .get("gaps")
2279 .and_then(|g| g.as_array())
2280 .map(|g| g.len())
2281 .unwrap_or(0);
2282 let via = if harnesses.is_empty() {
2283 "no harness state".to_string()
2284 } else {
2285 harnesses.join(", ")
2286 };
2287 return VerifyCheck::pass(
2288 "coverage",
2289 &format!(
2290 "{}: 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",
2291 entry.artifact_id
2292 ),
2293 );
2294 }
2295 VerifyCheck::warn(
2296 "coverage",
2297 "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)",
2298 )
2299}
2300
2301struct SealedAction {
2303 action: String,
2304 actor: String,
2305 retry: Option<serde_json::Value>,
2306 idempotency_key: Option<String>,
2309 effect_signature: Option<String>,
2312 effect_verified: bool,
2313}
2314
2315fn retries_check(pkg_dir: &Path, receipt: &SessionReceipt) -> Option<VerifyCheck> {
2318 use std::collections::{BTreeMap, BTreeSet};
2319 let art_dir = pkg_dir.join(ARTIFACTS_DIR);
2320 let mut actions: BTreeMap<String, SealedAction> = BTreeMap::new();
2321 for entry in &receipt.artifacts {
2322 let path = art_dir.join(format!("{}.json", sanitize_filename(&entry.artifact_id)));
2323 let Ok(raw) = std::fs::read(&path) else {
2324 continue;
2325 };
2326 let Ok(env) = crate::attestation::Envelope::from_json(&raw) else {
2327 continue;
2328 };
2329 let Some(stmt) = env
2330 .payload_bytes()
2331 .ok()
2332 .and_then(|b| serde_json::from_slice::<serde_json::Value>(&b).ok())
2333 else {
2334 continue;
2335 };
2336 let ty = stmt.get("type").and_then(|t| t.as_str()).unwrap_or("");
2337 if !ty.contains("/action/") {
2338 continue;
2339 }
2340 let effect = stmt.get("effect");
2341 let effect_signature = effect
2342 .and_then(|e| e.get("readback"))
2343 .and_then(|v| v.as_str())
2344 .or_else(|| {
2345 effect
2346 .and_then(|e| e.get("output_hash"))
2347 .and_then(|v| v.as_str())
2348 })
2349 .or_else(|| {
2350 stmt.get("meta")
2351 .and_then(|m| m.get("output_digest"))
2352 .and_then(|v| v.as_str())
2353 })
2354 .map(str::to_string);
2355 let effect_verified = matches!(
2356 effect
2357 .and_then(|e| e.get("effect_confidence"))
2358 .and_then(|v| v.as_str()),
2359 Some("verified") | Some("partial")
2360 );
2361 actions.insert(
2362 entry.artifact_id.clone(),
2363 SealedAction {
2364 action: stmt
2365 .get("action")
2366 .and_then(|v| v.as_str())
2367 .unwrap_or("")
2368 .to_string(),
2369 actor: stmt
2370 .get("actor")
2371 .and_then(|v| v.as_str())
2372 .unwrap_or("")
2373 .to_string(),
2374 idempotency_key: stmt
2375 .get("idempotencyKey")
2376 .or_else(|| stmt.get("idempotency_key"))
2377 .or_else(|| stmt.get("retry").and_then(|r| r.get("idempotency_key")))
2378 .and_then(|v| v.as_str())
2379 .map(str::to_string),
2380 retry: stmt.get("retry").cloned(),
2381 effect_signature,
2382 effect_verified,
2383 },
2384 );
2385 }
2386 let retries: Vec<(&String, &SealedAction)> =
2387 actions.iter().filter(|(_, a)| a.retry.is_some()).collect();
2388 if retries.is_empty() {
2389 return None;
2390 }
2391 let mut problems: Vec<String> = Vec::new();
2392 let mut chains: BTreeSet<String> = BTreeSet::new();
2393 for (id, a) in &retries {
2394 let r = a.retry.as_ref().unwrap();
2395 let of = r.get("of").and_then(|v| v.as_str()).unwrap_or("");
2396 let attempt = r.get("attempt").and_then(|v| v.as_u64()).unwrap_or(0);
2397 let cause = r.get("cause").and_then(|v| v.as_str()).unwrap_or("unknown");
2398 let key = r.get("idempotency_key").and_then(|v| v.as_str());
2399 let Some(prev) = actions.get(of) else {
2400 problems.push(format!(
2401 "{id} (attempt {attempt}, {cause}) retries {of}, which is not in this package"
2402 ));
2403 chains.insert(of.to_string());
2404 continue;
2405 };
2406 let mut root = of.to_string();
2408 let mut hops = 0;
2409 while let Some(p) = actions.get(&root) {
2410 match p
2411 .retry
2412 .as_ref()
2413 .and_then(|x| x.get("of"))
2414 .and_then(|v| v.as_str())
2415 {
2416 Some(next) if hops < 64 => {
2417 root = next.to_string();
2418 hops += 1;
2419 }
2420 _ => break,
2421 }
2422 }
2423 chains.insert(root);
2424 if prev.action != a.action || prev.actor != a.actor {
2425 problems.push(format!(
2426 "{id} retries {of} but is a different action or actor ({} by {} vs {} by {})",
2427 a.action, a.actor, prev.action, prev.actor
2428 ));
2429 }
2430 let prev_attempt = prev
2431 .retry
2432 .as_ref()
2433 .and_then(|x| x.get("attempt"))
2434 .and_then(|v| v.as_u64())
2435 .unwrap_or(1);
2436 if attempt != prev_attempt + 1 {
2437 problems.push(format!(
2438 "{id} is attempt {attempt} but retries attempt {prev_attempt}"
2439 ));
2440 }
2441 let prev_key = prev.idempotency_key.as_deref();
2442 if let (Some(k), Some(pk)) = (key, prev_key) {
2443 if k != pk {
2444 problems.push(format!(
2445 "{id} retries {of} with a different idempotency key: the second attempt is not idempotent with the first"
2446 ));
2447 }
2448 }
2449 if let (Some(cur), Some(before)) = (&a.effect_signature, &prev.effect_signature) {
2450 if cur != before {
2451 problems.push(format!(
2452 "{id} and {of} both report an effect and they differ ({} vs {}): two mutations, not one recovery",
2453 &cur[..cur.len().min(24)],
2454 &before[..before.len().min(24)]
2455 ));
2456 }
2457 }
2458 if cause == "timeout" && prev.effect_verified {
2459 problems.push(format!(
2460 "{id} retried {of} for a timeout, but {of} reports a verified effect: the first attempt landed"
2461 ));
2462 }
2463 }
2464 let n = retries.len();
2465 let c = chains.len();
2466 if problems.is_empty() {
2467 Some(VerifyCheck::pass(
2468 "retries",
2469 &format!(
2470 "{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"
2471 ),
2472 ))
2473 } else {
2474 Some(VerifyCheck::warn(
2475 "retries",
2476 &format!(
2477 "{n} retry attempt(s) across {c} chain(s); {}: {}",
2478 problems.len(),
2479 problems.join("; ")
2480 ),
2481 ))
2482 }
2483}
2484
2485fn judgements_check(pkg_dir: &Path, receipt: &SessionReceipt) -> Option<VerifyCheck> {
2489 let art_dir = pkg_dir.join(ARTIFACTS_DIR);
2490 let mut total = 0usize;
2491 let mut judges: BTreeSet<String> = BTreeSet::new();
2492 let mut flagged: Vec<String> = Vec::new();
2493 for entry in &receipt.artifacts {
2494 let path = art_dir.join(format!("{}.json", sanitize_filename(&entry.artifact_id)));
2495 let Ok(raw) = std::fs::read(&path) else {
2496 continue;
2497 };
2498 let Ok(env) = crate::attestation::Envelope::from_json(&raw) else {
2499 continue;
2500 };
2501 let Some(stmt) = env
2502 .payload_bytes()
2503 .ok()
2504 .and_then(|b| serde_json::from_slice::<serde_json::Value>(&b).ok())
2505 else {
2506 continue;
2507 };
2508 if stmt.get("kind").and_then(|k| k.as_str()) != Some("judgement.v1") {
2509 continue;
2510 }
2511 let Some(p) = stmt.get("payload") else {
2512 continue;
2513 };
2514 total += 1;
2515 if let Some(m) = p
2516 .get("judge")
2517 .and_then(|j| j.get("model"))
2518 .and_then(|v| v.as_str())
2519 {
2520 judges.insert(m.to_string());
2521 }
2522 let outcome = p.get("outcome").and_then(|v| v.as_str()).unwrap_or("");
2523 if outcome != "acted" {
2524 continue;
2525 }
2526 let threshold = p
2527 .get("threshold")
2528 .and_then(|t| t.get("value"))
2529 .and_then(|v| v.as_f64());
2530 let applies_to = p
2531 .get("threshold")
2532 .and_then(|t| t.get("applies_to"))
2533 .and_then(|v| v.as_str())
2534 .unwrap_or("confidence");
2535 let answer = p.get("answer");
2536 let measured = match applies_to {
2537 "noul" => answer.and_then(|a| a.get("noul")).and_then(|v| v.as_f64()),
2538 _ => answer
2539 .and_then(|a| a.get("confidence"))
2540 .and_then(|v| v.as_f64())
2541 .or_else(|| answer.and_then(|a| a.get("noul")).and_then(|v| v.as_f64())),
2542 };
2543 let effect = p.get("effect").and_then(|v| v.as_str());
2553 match (threshold, measured) {
2554 (None, _) => flagged.push(format!(
2555 "{} acted with no threshold declared",
2556 entry.artifact_id
2557 )),
2558 (Some(t), Some(m)) if applies_to == "noul" => {
2559 let yes = m >= t;
2560 let refusing = matches!(effect, Some("deny") | Some("ask"));
2561 let allowing = matches!(effect, Some("allow") | Some("warn"));
2562 if yes && allowing {
2563 flagged.push(format!(
2564 "{} acted to {} at noul {m:.3}, at or above its threshold {t:.3} (the answer was yes)",
2565 entry.artifact_id,
2566 effect.unwrap_or("")
2567 ));
2568 } else if !yes && refusing {
2569 flagged.push(format!(
2570 "{} acted to {} at noul {m:.3}, below its threshold {t:.3} (the answer was no)",
2571 entry.artifact_id,
2572 effect.unwrap_or("")
2573 ));
2574 } else if effect.is_none() && !yes {
2575 flagged.push(format!(
2576 "{} acted at noul {m:.3} below its threshold {t:.3} with no effect recorded",
2577 entry.artifact_id
2578 ));
2579 }
2580 }
2581 (Some(t), Some(m)) if m < t => flagged.push(format!(
2582 "{} acted at {applies_to} {m:.3} below its threshold {t:.3}",
2583 entry.artifact_id
2584 )),
2585 (Some(t), None) => flagged.push(format!(
2586 "{} acted against a threshold of {t:.3} on {applies_to} but the answer carries no {applies_to}",
2587 entry.artifact_id
2588 )),
2589 _ => {}
2590 }
2591 }
2592 if total == 0 {
2593 return None;
2594 }
2595 let who = judges.into_iter().collect::<Vec<_>>().join(", ");
2596 if flagged.is_empty() {
2597 Some(VerifyCheck::pass(
2598 "judgements",
2599 &format!(
2600 "{total} judgement(s) by {who}; every one acted on met its declared threshold. The row reads the caller's record; it does not re-run a judge"
2601 ),
2602 ))
2603 } else {
2604 Some(VerifyCheck::warn(
2605 "judgements",
2606 &format!(
2607 "{total} judgement(s) by {who}; {} acted on outside its own bar: {}",
2608 flagged.len(),
2609 flagged.join("; ")
2610 ),
2611 ))
2612 }
2613}
2614
2615#[cfg(test)]
2616mod tests {
2617 use super::*;
2618 use crate::session::event::*;
2619 use crate::session::manifest::SessionManifest;
2620 use crate::session::receipt::{ArtifactEntry, ReceiptComposer};
2621
2622 fn make_receipt() -> SessionReceipt {
2623 let manifest = SessionManifest::new(
2624 "ssn_pkg_test".into(),
2625 "agent://test".into(),
2626 "2026-04-05T08:00:00Z".into(),
2627 1743843600000,
2628 );
2629
2630 let mk = |seq: u64, inst: &str, et: EventType| -> SessionEvent {
2631 SessionEvent {
2632 session_id: "ssn_pkg_test".into(),
2633 event_id: format!("evt_{:016x}", seq),
2634 timestamp: format!("2026-04-05T08:{:02}:00Z", seq),
2635 sequence_no: seq,
2636 trace_id: "trace_1".into(),
2637 span_id: format!("span_{seq}"),
2638 parent_span_id: None,
2639 agent_id: format!("agent://{inst}"),
2640 agent_instance_id: inst.into(),
2641 agent_name: inst.into(),
2642 agent_role: None,
2643 host_id: "host_1".into(),
2644 tool_runtime_id: None,
2645 event_type: et,
2646 artifact_ref: None,
2647 meta: None,
2648 }
2649 };
2650
2651 let events = vec![
2652 mk(0, "root", EventType::SessionStarted),
2653 mk(
2654 1,
2655 "root",
2656 EventType::AgentStarted {
2657 parent_agent_instance_id: None,
2658 },
2659 ),
2660 mk(
2661 2,
2662 "root",
2663 EventType::AgentCalledTool {
2664 tool_name: "read_file".into(),
2665 tool_input_digest: None,
2666 tool_output_digest: None,
2667 duration_ms: Some(10),
2668 },
2669 ),
2670 mk(
2671 3,
2672 "root",
2673 EventType::AgentCompleted {
2674 termination_reason: None,
2675 },
2676 ),
2677 mk(
2678 4,
2679 "root",
2680 EventType::SessionClosed {
2681 summary: Some("Done".into()),
2682 duration_ms: Some(60000),
2683 },
2684 ),
2685 ];
2686
2687 let artifacts = vec![ArtifactEntry {
2688 artifact_id: "art_001".into(),
2689 payload_type: "action".into(),
2690 digest: None,
2691 signed_at: None,
2692 unchained: false,
2693 }];
2694
2695 ReceiptComposer::compose(&manifest, &events, artifacts)
2696 }
2697
2698 #[test]
2699 fn build_and_read_package() {
2700 let receipt = make_receipt();
2701 let tmp = std::env::temp_dir().join(format!("treeship-pkg-test-{}", rand::random::<u32>()));
2702
2703 let output = build_package(&receipt, &tmp).unwrap();
2704 assert!(output.path.exists());
2705 assert!(output.path.join("receipt.json").exists());
2706 assert!(output.path.join("merkle.json").exists());
2707 assert!(output.path.join("render.json").exists());
2708 assert!(output.path.join("preview.html").exists());
2709 assert!(output.receipt_digest.starts_with("sha256:"));
2710 assert!(output.file_count >= 4);
2711
2712 let read_back = read_package(&output.path).unwrap();
2714 assert_eq!(read_back.session.id, "ssn_pkg_test");
2715 assert_eq!(read_back.type_, RECEIPT_TYPE);
2716
2717 let _ = std::fs::remove_dir_all(&tmp);
2718 }
2719
2720 #[test]
2721 fn verify_valid_package() {
2722 let receipt = make_receipt();
2723 let tmp =
2724 std::env::temp_dir().join(format!("treeship-pkg-verify-{}", rand::random::<u32>()));
2725
2726 let output = build_package(&receipt, &tmp).unwrap();
2727 let checks = verify_package_structural(&output.path).unwrap();
2728
2729 let fails: Vec<_> = checks
2730 .iter()
2731 .filter(|c| c.status == VerifyStatus::Fail)
2732 .collect();
2733 assert!(fails.is_empty(), "unexpected failures: {fails:?}");
2734
2735 let passes: Vec<_> = checks
2736 .iter()
2737 .filter(|c| c.status == VerifyStatus::Pass)
2738 .collect();
2739 assert!(
2740 passes.len() >= 5,
2741 "expected at least 5 pass checks, got {}",
2742 passes.len()
2743 );
2744
2745 let _ = std::fs::remove_dir_all(&tmp);
2746 }
2747
2748 #[test]
2752 fn verify_warns_when_reconcile_degraded() {
2753 let mut receipt = make_receipt();
2754 receipt.proofs.reconcile_degraded = true;
2755 let tmp =
2756 std::env::temp_dir().join(format!("treeship-pkg-degraded-{}", rand::random::<u32>()));
2757
2758 let output = build_package(&receipt, &tmp).unwrap();
2759 let checks = verify_package_structural(&output.path).unwrap();
2760
2761 let warned = checks
2762 .iter()
2763 .any(|c| c.name == "reconcile_degraded" && c.status == VerifyStatus::Warn);
2764 assert!(warned, "expected a reconcile_degraded WARN, got {checks:?}");
2765 let fails: Vec<_> = checks
2767 .iter()
2768 .filter(|c| c.status == VerifyStatus::Fail)
2769 .collect();
2770 assert!(fails.is_empty(), "must not hard-fail: {fails:?}");
2771
2772 let _ = std::fs::remove_dir_all(&tmp);
2773 }
2774
2775 #[test]
2776 fn verify_no_degraded_warn_when_clean() {
2777 let receipt = make_receipt();
2779 let tmp =
2780 std::env::temp_dir().join(format!("treeship-pkg-clean-{}", rand::random::<u32>()));
2781 let output = build_package(&receipt, &tmp).unwrap();
2782 let checks = verify_package_structural(&output.path).unwrap();
2783 assert!(
2784 !checks.iter().any(|c| c.name == "reconcile_degraded"),
2785 "clean receipt must not emit a reconcile_degraded check"
2786 );
2787 let _ = std::fs::remove_dir_all(&tmp);
2788 }
2789
2790 #[test]
2791 fn verify_detects_missing_receipt() {
2792 let tmp =
2793 std::env::temp_dir().join(format!("treeship-pkg-empty-{}", rand::random::<u32>()));
2794 std::fs::create_dir_all(&tmp).unwrap();
2795
2796 let err = read_package(&tmp);
2797 assert!(err.is_err());
2798
2799 let _ = std::fs::remove_dir_all(&tmp);
2800 }
2801
2802 #[test]
2803 fn preview_html_renders_approval_evidence_from_the_bundle() {
2804 use crate::attestation::sign::sign;
2809 use crate::attestation::Ed25519Signer;
2810 use crate::statements::ApprovalScope;
2811 use crate::statements::TYPE_APPROVAL_USE;
2812
2813 let receipt = make_receipt();
2814 assert_eq!(preview_approvals_json(None), serde_json::Value::Null);
2815 assert_eq!(
2816 preview_approvals_json(Some(&ApprovalsBundle::default())),
2817 serde_json::Value::Null,
2818 "an empty bundle is the same as none"
2819 );
2820
2821 let signer = Ed25519Signer::generate("key_test_preview").unwrap();
2822 let mut grant = ApprovalStatement::new("human://operator", "nonce-preview-0001");
2823 grant.description = Some("apply change chg-0001: 3% clearance".into());
2824 grant.scope = Some(ApprovalScope {
2825 max_actions: Some(1),
2826 valid_until: None,
2827 allowed_actors: vec!["agent://merchant".into()],
2828 allowed_actions: vec!["commerce.tool.apply_change.intent".into()],
2829 allowed_subjects: vec!["change://chg-0001".into()],
2830 extra: None,
2831 });
2832 let signed = sign("application/vnd.treeship.approval.v1+json", &grant, &signer).unwrap();
2833 let grant_id = signed.artifact_id.to_string();
2834 let grant_bytes = serde_json::to_vec(&signed.envelope).unwrap();
2835
2836 let use_record = ApprovalUse {
2837 type_: TYPE_APPROVAL_USE.into(),
2838 use_id: "use_preview_0001".into(),
2839 grant_id: grant_id.clone(),
2840 grant_digest: signed.digest.clone(),
2841 nonce_digest: "sha256:00".into(),
2842 actor: "agent://merchant".into(),
2843 action: "commerce.tool.apply_change.intent".into(),
2844 subject: "change://chg-0001".into(),
2845 session_id: Some("ssn_pkg_test".into()),
2846 action_artifact_id: Some("art_apply_intent".into()),
2847 receipt_digest: None,
2848 use_number: 1,
2849 max_uses: Some(1),
2850 idempotency_key: None,
2851 created_at: "2026-09-07T10:45:49Z".into(),
2852 expires_at: None,
2853 previous_record_digest: String::new(),
2854 record_digest: String::new(),
2855 signature: None,
2856 signature_alg: None,
2857 signing_key_id: None,
2858 };
2859 let bundle = ApprovalsBundle {
2860 grants: vec![
2861 (grant_id.clone(), grant_bytes),
2862 ("art_garbage".into(), b"not json".to_vec()),
2863 ],
2864 uses: vec![use_record],
2865 ..Default::default()
2866 };
2867
2868 let summary = preview_approvals_json(Some(&bundle));
2869 let grants = summary["grants"].as_array().unwrap();
2870 assert_eq!(grants.len(), 2);
2871 assert_eq!(grants[0]["parsed"], true);
2872 assert_eq!(grants[0]["approver"], "human://operator");
2873 assert_eq!(grants[0]["scope"]["max_uses"], 1);
2874 assert_eq!(
2875 grants[0]["scope"]["allowed_subjects"][0],
2876 "change://chg-0001"
2877 );
2878 assert_eq!(grants[1]["parsed"], false);
2880 assert_eq!(grants[1]["grant_id"], "art_garbage");
2881 let uses = summary["uses"].as_array().unwrap();
2882 assert_eq!(uses[0]["use_number"], 1);
2883 assert_eq!(uses[0]["action_artifact_id"], "art_apply_intent");
2884
2885 let html = render_preview_html_with_approvals(&receipt, Some(&bundle));
2886 assert!(html.contains("id=\"approvals-data\""));
2887 assert!(html.contains("\"approver\":\"human://operator\""));
2888 assert!(html.contains("\"subject\":\"change://chg-0001\""));
2889 assert!(
2890 !html.contains("__APPROVALS_JSON__"),
2891 "placeholder must be substituted"
2892 );
2893 let plain = render_preview_html(&receipt);
2896 assert!(plain.contains("type=\"application/json\">null</script>"));
2897 }
2898
2899 #[test]
2900 fn preview_html_contains_session_info() {
2901 let receipt = make_receipt();
2902 let html = render_preview_html(&receipt);
2903 assert!(html.contains("ssn_pkg_test"));
2904 assert!(html.contains("treeship.dev"));
2905 assert!(html.contains("Timeline"));
2906
2907 assert!(
2917 html.contains("'__RECEIPT'+'_JSON__'"),
2918 "JS placeholder check was clobbered by the receipt substitution",
2919 );
2920 assert!(
2921 !html.contains("application/json\">__RECEIPT_JSON__</script>"),
2922 "data slot was not substituted with the receipt JSON",
2923 );
2924 assert_eq!(
2928 html.matches("__RECEIPT_JSON__").count(),
2929 0,
2930 "no raw placeholder token should remain after substitution",
2931 );
2932 }
2933}