1use std::path::{Path, PathBuf};
13
14use serde::{Deserialize, Serialize};
15use sha2::{Digest, Sha256};
16
17use super::receipt::{SessionReceipt, RECEIPT_TYPE};
18use crate::statements::{
19 approval_revocation_record_digest, approval_use_record_digest,
20 journal_checkpoint_record_digest, ApprovalRevocation, ApprovalUse, JournalCheckpoint,
21 ReplayCheck, ReplayCheckLevel,
22};
23
24#[derive(Debug)]
26pub enum PackageError {
27 Io(std::io::Error),
28 Json(serde_json::Error),
29 InvalidPackage(String),
30}
31
32impl std::fmt::Display for PackageError {
33 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
34 match self {
35 Self::Io(e) => write!(f, "package io: {e}"),
36 Self::Json(e) => write!(f, "package json: {e}"),
37 Self::InvalidPackage(msg) => write!(f, "invalid package: {msg}"),
38 }
39 }
40}
41
42impl std::error::Error for PackageError {}
43impl From<std::io::Error> for PackageError {
44 fn from(e: std::io::Error) -> Self {
45 Self::Io(e)
46 }
47}
48impl From<serde_json::Error> for PackageError {
49 fn from(e: serde_json::Error) -> Self {
50 Self::Json(e)
51 }
52}
53
54const RECEIPT_FILE: &str = "receipt.json";
56const MERKLE_FILE: &str = "merkle.json";
57const RENDER_FILE: &str = "render.json";
58const ARTIFACTS_DIR: &str = "artifacts";
59const PROOFS_DIR: &str = "proofs";
60const PREVIEW_FILE: &str = "preview.html";
61
62const APPROVALS_DIR: &str = "approvals";
75const APPROVALS_GRANTS: &str = "approvals/grants";
76const APPROVALS_USES: &str = "approvals/uses";
77const APPROVALS_CHECKPOINTS: &str = "approvals/checkpoints";
78const APPROVALS_INDEX_FILE: &str = "approvals/index.json";
79
80#[derive(Debug, Clone, Default)]
90pub struct ApprovalsBundle {
91 pub grants: Vec<(String, Vec<u8>)>,
96 pub uses: Vec<ApprovalUse>,
100 pub checkpoints: Vec<JournalCheckpoint>,
103 pub revocations: Vec<ApprovalRevocation>,
108
109 pub action_envelopes: Vec<(String, Vec<u8>)>,
117}
118
119#[derive(Debug, Clone, Serialize, Deserialize)]
123pub struct ApprovalsIndex {
124 #[serde(rename = "type")]
126 pub type_: String,
127 pub schema_version: u32,
128 pub grants: Vec<String>,
131 pub uses: Vec<String>,
133 pub checkpoints: Vec<String>,
134 pub revocations: Vec<String>,
135}
136
137impl ApprovalsIndex {
138 pub fn type_string() -> &'static str {
139 "treeship/approvals-index/v1"
140 }
141}
142
143pub struct PackageOutput {
145 pub path: PathBuf,
147 pub receipt_digest: String,
149 pub merkle_root: Option<String>,
151 pub file_count: usize,
153}
154
155pub fn build_package(
165 receipt: &SessionReceipt,
166 output_dir: &Path,
167) -> Result<PackageOutput, PackageError> {
168 build_package_with_approvals(receipt, output_dir, None)
169}
170
171pub fn build_package_with_approvals(
175 receipt: &SessionReceipt,
176 output_dir: &Path,
177 bundle: Option<&ApprovalsBundle>,
178) -> Result<PackageOutput, PackageError> {
179 let session_id = &receipt.session.id;
180 let pkg_dir = output_dir.join(format!("{session_id}.treeship"));
181
182 std::fs::create_dir_all(&pkg_dir)?;
183 std::fs::create_dir_all(pkg_dir.join(ARTIFACTS_DIR))?;
184 std::fs::create_dir_all(pkg_dir.join(PROOFS_DIR))?;
185
186 let mut file_count = 0usize;
187
188 let receipt_bytes = serde_json::to_vec_pretty(receipt)?;
190 std::fs::write(pkg_dir.join(RECEIPT_FILE), &receipt_bytes)?;
191 file_count += 1;
192
193 let receipt_hash = Sha256::digest(&receipt_bytes);
194 let receipt_digest = format!("sha256:{}", hex::encode(receipt_hash));
195
196 let merkle_bytes = serde_json::to_vec_pretty(&receipt.merkle)?;
198 std::fs::write(pkg_dir.join(MERKLE_FILE), &merkle_bytes)?;
199 file_count += 1;
200
201 let render_bytes = serde_json::to_vec_pretty(&receipt.render)?;
203 std::fs::write(pkg_dir.join(RENDER_FILE), &render_bytes)?;
204 file_count += 1;
205
206 for proof_entry in &receipt.merkle.inclusion_proofs {
208 let proof_bytes = serde_json::to_vec_pretty(proof_entry)?;
209 let filename = format!("{}.proof.json", proof_entry.artifact_id);
210 std::fs::write(pkg_dir.join(PROOFS_DIR).join(filename), &proof_bytes)?;
211 file_count += 1;
212 }
213
214 if receipt.render.generate_preview {
216 let preview = render_preview_html(receipt);
217 std::fs::write(pkg_dir.join(PREVIEW_FILE), preview.as_bytes())?;
218 file_count += 1;
219 }
220
221 if let Some(b) = bundle {
226 if !b.grants.is_empty()
227 || !b.uses.is_empty()
228 || !b.checkpoints.is_empty()
229 || !b.revocations.is_empty()
230 || !b.action_envelopes.is_empty()
231 {
232 std::fs::create_dir_all(pkg_dir.join(APPROVALS_GRANTS))?;
233 std::fs::create_dir_all(pkg_dir.join(APPROVALS_USES))?;
234 std::fs::create_dir_all(pkg_dir.join(APPROVALS_CHECKPOINTS))?;
235 std::fs::create_dir_all(pkg_dir.join(ARTIFACTS_DIR))?;
241 for (artifact_id, envelope_bytes) in &b.action_envelopes {
242 let safe = sanitize_filename(artifact_id);
243 std::fs::write(
244 pkg_dir.join(ARTIFACTS_DIR).join(format!("{safe}.json")),
245 envelope_bytes,
246 )?;
247 file_count += 1;
248 }
249
250 let mut grant_ids = Vec::with_capacity(b.grants.len());
251 for (grant_id, envelope_bytes) in &b.grants {
252 let safe = sanitize_filename(grant_id);
253 std::fs::write(
254 pkg_dir.join(APPROVALS_GRANTS).join(format!("{safe}.json")),
255 envelope_bytes,
256 )?;
257 grant_ids.push(grant_id.clone());
258 file_count += 1;
259 }
260
261 let mut use_ids = Vec::with_capacity(b.uses.len());
262 for u in &b.uses {
263 let safe = sanitize_filename(&u.use_id);
264 let bytes = serde_json::to_vec_pretty(u)?;
265 std::fs::write(
266 pkg_dir.join(APPROVALS_USES).join(format!("{safe}.json")),
267 &bytes,
268 )?;
269 use_ids.push(u.use_id.clone());
270 file_count += 1;
271 }
272
273 let mut checkpoint_ids = Vec::with_capacity(b.checkpoints.len());
274 for cp in &b.checkpoints {
275 let safe = sanitize_filename(&cp.checkpoint_id);
276 let bytes = serde_json::to_vec_pretty(cp)?;
277 std::fs::write(
278 pkg_dir
279 .join(APPROVALS_CHECKPOINTS)
280 .join(format!("{safe}.json")),
281 &bytes,
282 )?;
283 checkpoint_ids.push(cp.checkpoint_id.clone());
284 file_count += 1;
285 }
286
287 let mut revocation_ids = Vec::with_capacity(b.revocations.len());
288 for rev in &b.revocations {
289 let safe = sanitize_filename(&rev.revocation_id);
290 let bytes = serde_json::to_vec_pretty(rev)?;
291 std::fs::write(
292 pkg_dir
293 .join(APPROVALS_DIR)
294 .join(format!("revocations-{safe}.json")),
295 &bytes,
296 )?;
297 revocation_ids.push(rev.revocation_id.clone());
298 file_count += 1;
299 }
300
301 let index = ApprovalsIndex {
302 type_: ApprovalsIndex::type_string().into(),
303 schema_version: 1,
304 grants: grant_ids,
305 uses: use_ids,
306 checkpoints: checkpoint_ids,
307 revocations: revocation_ids,
308 };
309 let index_bytes = serde_json::to_vec_pretty(&index)?;
310 std::fs::write(pkg_dir.join(APPROVALS_INDEX_FILE), &index_bytes)?;
311 file_count += 1;
312 }
313 }
314
315 Ok(PackageOutput {
316 path: pkg_dir,
317 receipt_digest,
318 merkle_root: receipt.merkle.root.clone(),
319 file_count,
320 })
321}
322
323fn sanitize_filename(s: &str) -> String {
327 s.chars()
328 .map(|c| {
329 if c.is_ascii_alphanumeric() || c == '-' || c == '.' || c == '_' {
330 c
331 } else {
332 '_'
333 }
334 })
335 .collect()
336}
337
338pub fn read_approvals_bundle(pkg_dir: &Path) -> Result<ApprovalsBundle, PackageError> {
348 let approvals_dir = pkg_dir.join(APPROVALS_DIR);
349 if !approvals_dir.is_dir() {
350 return Ok(ApprovalsBundle::default());
351 }
352
353 let mut bundle = ApprovalsBundle::default();
354
355 let grants_dir = pkg_dir.join(APPROVALS_GRANTS);
358 if grants_dir.is_dir() {
359 for entry in std::fs::read_dir(&grants_dir)? {
360 let entry = entry?;
361 let path = entry.path();
362 if path.extension().and_then(|s| s.to_str()) != Some("json") {
363 continue;
364 }
365 let id = path
366 .file_stem()
367 .and_then(|s| s.to_str())
368 .unwrap_or("")
369 .to_string();
370 let bytes = std::fs::read(&path)?;
371 bundle.grants.push((id, bytes));
372 }
373 }
374
375 let uses_dir = pkg_dir.join(APPROVALS_USES);
376 if uses_dir.is_dir() {
377 for entry in std::fs::read_dir(&uses_dir)? {
378 let entry = entry?;
379 let path = entry.path();
380 if path.extension().and_then(|s| s.to_str()) != Some("json") {
381 continue;
382 }
383 let bytes = std::fs::read(&path)?;
384 let u: ApprovalUse = serde_json::from_slice(&bytes)?;
385 bundle.uses.push(u);
386 }
387 }
388
389 let cps_dir = pkg_dir.join(APPROVALS_CHECKPOINTS);
390 if cps_dir.is_dir() {
391 for entry in std::fs::read_dir(&cps_dir)? {
392 let entry = entry?;
393 let path = entry.path();
394 if path.extension().and_then(|s| s.to_str()) != Some("json") {
395 continue;
396 }
397 let bytes = std::fs::read(&path)?;
398 let cp: JournalCheckpoint = serde_json::from_slice(&bytes)?;
399 bundle.checkpoints.push(cp);
400 }
401 }
402
403 let arts_dir = pkg_dir.join(ARTIFACTS_DIR);
410 if arts_dir.is_dir() {
411 for entry in std::fs::read_dir(&arts_dir)? {
412 let entry = entry?;
413 let path = entry.path();
414 if path.extension().and_then(|s| s.to_str()) != Some("json") {
415 continue;
416 }
417 let id = path
418 .file_stem()
419 .and_then(|s| s.to_str())
420 .unwrap_or("")
421 .to_string();
422 let bytes = std::fs::read(&path)?;
423 bundle.action_envelopes.push((id, bytes));
424 }
425 }
426
427 Ok(bundle)
428}
429
430pub fn read_package(pkg_dir: &Path) -> Result<SessionReceipt, PackageError> {
432 let receipt_path = pkg_dir.join(RECEIPT_FILE);
433 if !receipt_path.exists() {
434 return Err(PackageError::InvalidPackage(format!(
435 "missing {RECEIPT_FILE} in {}",
436 pkg_dir.display()
437 )));
438 }
439 let bytes = std::fs::read(&receipt_path)?;
440 let receipt: SessionReceipt = serde_json::from_slice(&bytes)?;
441
442 if receipt.type_ != RECEIPT_TYPE {
443 return Err(PackageError::InvalidPackage(format!(
444 "unexpected type: {} (expected {RECEIPT_TYPE})",
445 receipt.type_
446 )));
447 }
448
449 Ok(receipt)
450}
451
452pub fn verify_package(pkg_dir: &Path) -> Result<Vec<VerifyCheck>, PackageError> {
470 let trust = match crate::trust::TrustRootStore::open_default_or_empty() {
471 Ok(t) => t,
472 Err(e) => {
473 return Ok(vec![VerifyCheck::fail(
477 "trust-root",
478 &format!("trust store unreadable: {e}"),
479 )]);
480 }
481 };
482 verify_package_with_trust(pkg_dir, &trust)
483}
484
485pub fn verify_package_with_trust(
489 pkg_dir: &Path,
490 trust: &crate::trust::TrustRootStore,
491) -> Result<Vec<VerifyCheck>, PackageError> {
492 let mut checks = Vec::new();
493
494 let receipt = match read_package(pkg_dir) {
496 Ok(r) => {
497 checks.push(VerifyCheck::pass(
498 "receipt.json",
499 "Parses as valid Session Receipt",
500 ));
501 r
502 }
503 Err(e) => {
504 checks.push(VerifyCheck::fail(
505 "receipt.json",
506 &format!("Failed to parse: {e}"),
507 ));
508 return Ok(checks);
509 }
510 };
511
512 if receipt.type_ == RECEIPT_TYPE {
514 checks.push(VerifyCheck::pass("type", "Correct receipt type"));
515 } else {
516 checks.push(VerifyCheck::fail(
517 "type",
518 &format!("Expected {RECEIPT_TYPE}, got {}", receipt.type_),
519 ));
520 }
521
522 let receipt_path = pkg_dir.join(RECEIPT_FILE);
537 let on_disk = std::fs::read(&receipt_path)?;
538 let re_serialized = serde_json::to_vec_pretty(&receipt)?;
539 if on_disk == re_serialized {
540 checks.push(VerifyCheck::pass(
541 "determinism",
542 "receipt.json round-trips identically (structural, NOT a signature)",
543 ));
544 } else {
545 checks.push(VerifyCheck::warn(
547 "determinism",
548 "receipt.json does not byte-match after re-serialization",
549 ));
550 }
551
552 checks.push(VerifyCheck::warn(
559 "receipt_body_binding",
560 "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).",
561 ));
562
563 if !receipt.artifacts.is_empty() {
565 let version = receipt.merkle.merkle_version;
572 let mut tree = match crate::merkle::MerkleTree::with_version(version) {
573 Ok(t) => t,
574 Err(e) => {
575 checks.push(VerifyCheck::fail(
576 "merkle_root",
577 &format!("receipt declared unknown merkle_version: {e}"),
578 ));
579 return Ok(finish_package_checks(checks, &receipt));
582 }
583 };
584 for art in &receipt.artifacts {
585 tree.append(&art.artifact_id);
586 }
587 let root_bytes = tree.root();
588 let recomputed_root = root_bytes.map(|r| format!("mroot_{}", hex::encode(r)));
589 let root_hex = root_bytes.map(|r| hex::encode(r)).unwrap_or_default();
590
591 if recomputed_root == receipt.merkle.root {
592 checks.push(VerifyCheck::pass(
593 "merkle_root",
594 "Merkle root matches recomputed value",
595 ));
596 } else {
597 checks.push(VerifyCheck::fail(
598 "merkle_root",
599 &format!(
600 "Mismatch: on-disk {:?} vs recomputed {:?}",
601 receipt.merkle.root, recomputed_root
602 ),
603 ));
604 }
605
606 for proof_entry in &receipt.merkle.inclusion_proofs {
611 if proof_entry.proof.merkle_version != version {
612 checks.push(VerifyCheck::fail(
613 &format!("inclusion:{}", proof_entry.artifact_id),
614 &format!(
615 "proof merkle_version {} != receipt section v{}",
616 proof_entry.proof.merkle_version, version,
617 ),
618 ));
619 continue;
620 }
621 let verified = crate::merkle::MerkleTree::verify_proof(
622 version,
623 &root_hex,
624 &proof_entry.artifact_id,
625 &proof_entry.proof,
626 );
627 if verified {
628 checks.push(VerifyCheck::pass(
629 &format!("inclusion:{}", proof_entry.artifact_id),
630 "Inclusion proof valid",
631 ));
632 } else {
633 checks.push(VerifyCheck::fail(
634 &format!("inclusion:{}", proof_entry.artifact_id),
635 "Inclusion proof failed verification",
636 ));
637 }
638 }
639 } else {
640 checks.push(VerifyCheck::warn("merkle_root", "No artifacts to verify"));
641 }
642
643 if receipt.merkle.leaf_count == receipt.artifacts.len() {
645 checks.push(VerifyCheck::pass(
646 "leaf_count",
647 "Leaf count matches artifact count",
648 ));
649 } else {
650 checks.push(VerifyCheck::fail(
651 "leaf_count",
652 &format!(
653 "leaf_count {} != artifact count {}",
654 receipt.merkle.leaf_count,
655 receipt.artifacts.len()
656 ),
657 ));
658 }
659
660 let ordered = receipt.timeline.windows(2).all(|w| {
662 (&w[0].timestamp, w[0].sequence_no, &w[0].event_id)
663 <= (&w[1].timestamp, w[1].sequence_no, &w[1].event_id)
664 });
665 if ordered {
666 checks.push(VerifyCheck::pass(
667 "timeline_order",
668 "Timeline is correctly ordered",
669 ));
670 } else {
671 checks.push(VerifyCheck::fail(
672 "timeline_order",
673 "Timeline entries are not in deterministic order",
674 ));
675 }
676
677 if receipt.proofs.event_log_skipped > 0 {
685 checks.push(VerifyCheck::warn(
686 "event_log_completeness",
687 &format!(
688 "{} event(s) skipped during close (malformed lines in events.jsonl). \
689 Receipt is cryptographically valid but does not represent the full event stream. \
690 Inspect close-time stderr or the events.jsonl directly to investigate.",
691 receipt.proofs.event_log_skipped,
692 ),
693 ));
694 }
695
696 if receipt.proofs.reconcile_untracked_truncated > 0 {
697 checks.push(VerifyCheck::warn(
698 "reconcile_completeness",
699 &format!(
700 "untracked git reconcile exceeded cap {} (saw at least {}). \
701 Per-file synthetic events were skipped and the receipt is bounded, not complete for untracked files.",
702 receipt.proofs.reconcile_untracked_cap,
703 receipt.proofs.reconcile_untracked_truncated,
704 ),
705 ));
706 }
707
708 if receipt.proofs.reconcile_degraded {
714 checks.push(VerifyCheck::warn(
715 "reconcile_degraded",
716 "the git reconcile backstop was UNAVAILABLE at session close although git worked at start \
717 (.git removed, corrupt index, or git not on PATH). Files changed outside a captured \
718 AgentWroteFile event may be MISSING from this receipt's file ledger — treat the \
719 \"Files changed\" list as incomplete.",
720 ));
721 }
722
723 let bundle = read_approvals_bundle(pkg_dir).unwrap_or_default();
735 add_approval_evidence_checks(&mut checks, &bundle, trust);
736
737 Ok(checks)
738}
739
740fn finish_package_checks(
745 mut checks: Vec<VerifyCheck>,
746 receipt: &SessionReceipt,
747) -> Vec<VerifyCheck> {
748 if receipt.merkle.leaf_count == receipt.artifacts.len() {
749 checks.push(VerifyCheck::pass(
750 "leaf_count",
751 "Leaf count matches artifact count",
752 ));
753 } else {
754 checks.push(VerifyCheck::fail(
755 "leaf_count",
756 &format!(
757 "leaf_count {} != artifact count {}",
758 receipt.merkle.leaf_count,
759 receipt.artifacts.len(),
760 ),
761 ));
762 }
763
764 let ordered = receipt.timeline.windows(2).all(|w| {
765 (&w[0].timestamp, w[0].sequence_no, &w[0].event_id)
766 <= (&w[1].timestamp, w[1].sequence_no, &w[1].event_id)
767 });
768 if ordered {
769 checks.push(VerifyCheck::pass(
770 "timeline_order",
771 "Timeline is correctly ordered",
772 ));
773 } else {
774 checks.push(VerifyCheck::fail(
775 "timeline_order",
776 "Timeline entries are not in deterministic order",
777 ));
778 }
779
780 checks
781}
782
783pub(crate) fn add_approval_evidence_checks(
794 checks: &mut Vec<VerifyCheck>,
795 bundle: &ApprovalsBundle,
796 trust: &crate::trust::TrustRootStore,
797) {
798 if bundle.uses.is_empty() && bundle.checkpoints.is_empty() {
799 return;
803 }
804
805 use std::collections::HashMap;
814 let mut by_nonce: HashMap<(String, String), Vec<&ApprovalUse>> = HashMap::new();
815 let mut by_use_id: HashMap<&str, Vec<&ApprovalUse>> = HashMap::new();
816 for u in &bundle.uses {
817 by_nonce
818 .entry((u.grant_id.clone(), u.nonce_digest.clone()))
819 .or_default()
820 .push(u);
821 by_use_id.entry(&u.use_id).or_default().push(u);
822 }
823 let over_max: Vec<((String, String), Vec<&ApprovalUse>, u32)> = by_nonce
824 .iter()
825 .filter_map(|(key, uses)| {
826 let max = uses.iter().filter_map(|u| u.max_uses).next()?;
827 if (uses.len() as u32) > max {
828 Some((key.clone(), uses.iter().map(|u| *u).collect(), max))
829 } else {
830 None
831 }
832 })
833 .collect();
834 let dup_use_ids: Vec<(&&str, &Vec<&ApprovalUse>)> =
835 by_use_id.iter().filter(|(_, v)| v.len() > 1).collect();
836
837 if over_max.is_empty() && dup_use_ids.is_empty() {
838 checks.push(VerifyCheck::pass(
839 "replay-package-local",
840 &format!(
841 "no duplicate approval use inside package ({} uses scanned)",
842 bundle.uses.len()
843 ),
844 ));
845 } else {
846 let mut detail = String::from("package-local replay violation:");
847 for ((grant_id, _nd), uses, max) in &over_max {
848 detail.push_str(&format!(
849 " grant {grant_id} consumed {} times in this package (max_uses={max});",
850 uses.len(),
851 ));
852 }
853 for (uid, uses) in &dup_use_ids {
854 detail.push_str(&format!(" use_id {uid} appears {} times;", uses.len()));
855 }
856 checks.push(VerifyCheck::fail("replay-package-local", &detail));
857 }
858
859 if !bundle.checkpoints.is_empty() {
864 let mut tampered = Vec::new();
865 for cp in &bundle.checkpoints {
866 let recomputed = journal_checkpoint_record_digest(cp);
867 if recomputed != cp.record_digest {
868 tampered.push((
869 cp.checkpoint_id.clone(),
870 cp.record_digest.clone(),
871 recomputed,
872 ));
873 }
874 }
875 if tampered.is_empty() {
876 checks.push(VerifyCheck::pass(
877 "replay-included-checkpoint",
878 &format!(
879 "{} included journal checkpoint(s) verify offline",
880 bundle.checkpoints.len()
881 ),
882 ));
883 } else {
884 let detail = tampered
885 .iter()
886 .map(|(id, expected, actual)| {
887 format!("checkpoint {id} tampered (stored {expected}, recomputed {actual})")
888 })
889 .collect::<Vec<_>>()
890 .join("; ");
891 checks.push(VerifyCheck::fail("replay-included-checkpoint", &detail));
892 }
893 }
894
895 let mut tampered_uses = Vec::new();
905 for u in &bundle.uses {
906 let recomputed = approval_use_record_digest(u);
907 if recomputed != u.record_digest {
908 tampered_uses.push((u.use_id.clone(), u.record_digest.clone(), recomputed));
909 }
910 }
911 if !bundle.uses.is_empty() {
912 if tampered_uses.is_empty() {
913 checks.push(VerifyCheck::pass(
914 "approval-use-record-digest",
915 &format!("{} use record(s) recompute identically", bundle.uses.len()),
916 ));
917 } else {
918 let detail = tampered_uses
919 .iter()
920 .map(|(id, expected, actual)| {
921 format!("use {id} tampered (stored {expected}, recomputed {actual})")
922 })
923 .collect::<Vec<_>>()
924 .join("; ");
925 checks.push(VerifyCheck::fail("approval-use-record-digest", &detail));
926 }
927 }
928
929 if !bundle.uses.is_empty() {
947 use crate::attestation::envelope::Envelope;
948 use crate::attestation::{artifact_id_from_pae, pae};
949 use crate::statements::{nonce_digest, ApprovalStatement};
950 let mut grant_nonce_digest: std::collections::HashMap<String, String> =
951 std::collections::HashMap::new();
952 let mut tampered_grants: Vec<String> = Vec::new();
953 for (grant_id, env_bytes) in &bundle.grants {
954 let env = match Envelope::from_json(env_bytes) {
955 Ok(e) => e,
956 Err(_) => {
957 tampered_grants.push(format!("grant {grant_id} envelope unparseable"));
958 continue;
959 }
960 };
961 let derived = match env.payload_bytes() {
966 Ok(p) => artifact_id_from_pae(&pae(&env.payload_type, &p)),
967 Err(_) => {
968 tampered_grants.push(format!("grant {grant_id} envelope payload undecodable"));
969 continue;
970 }
971 };
972 if &derived != grant_id {
973 tampered_grants.push(format!(
974 "grant {grant_id} envelope content derives to {derived} -- envelope substituted or tampered",
975 ));
976 continue;
977 }
978 let approval: ApprovalStatement = match env.unmarshal_statement() {
979 Ok(a) => a,
980 Err(_) => {
981 tampered_grants
982 .push(format!("grant {grant_id} payload not an ApprovalStatement"));
983 continue;
984 }
985 };
986 grant_nonce_digest.insert(grant_id.clone(), nonce_digest(&approval.nonce));
987 }
988 let mut mismatches: Vec<String> = Vec::new();
989 let mut missing_grants: Vec<String> = Vec::new();
990 for u in &bundle.uses {
991 match grant_nonce_digest.get(&u.grant_id) {
992 Some(expected) => {
993 if expected != &u.nonce_digest {
994 mismatches.push(format!(
995 "use {} claims nonce_digest {} but grant {} signed nonce hashes to {}",
996 u.use_id, u.nonce_digest, u.grant_id, expected,
997 ));
998 }
999 }
1000 None => {
1001 missing_grants.push(format!(
1002 "use {} references grant {} but no usable grant envelope is in the package",
1003 u.use_id, u.grant_id,
1004 ));
1005 }
1006 }
1007 }
1008 if mismatches.is_empty() && missing_grants.is_empty() && tampered_grants.is_empty() {
1009 checks.push(VerifyCheck::pass(
1010 "approval-use-nonce-binding",
1011 &format!(
1012 "{} use record(s) bind to content-addressed grant signed nonces",
1013 bundle.uses.len(),
1014 ),
1015 ));
1016 } else {
1017 let mut parts: Vec<String> = Vec::new();
1018 if !tampered_grants.is_empty() {
1019 parts.push(tampered_grants.join("; "));
1020 }
1021 if !mismatches.is_empty() {
1022 parts.push(mismatches.join("; "));
1023 }
1024 if !missing_grants.is_empty() {
1025 parts.push(missing_grants.join("; "));
1026 }
1027 checks.push(VerifyCheck::fail(
1028 "approval-use-nonce-binding",
1029 &parts.join("; "),
1030 ));
1031 }
1032 }
1033
1034 if !bundle.uses.is_empty() {
1049 use crate::attestation::envelope::Envelope;
1050 use crate::attestation::{artifact_id_from_pae, pae};
1051 use crate::statements::{nonce_digest, ActionStatement};
1052 if bundle.action_envelopes.is_empty() {
1053 checks.push(VerifyCheck::warn(
1054 "approval-use-action-binding",
1055 "no action envelopes embedded -- action↔use binding not asserted by package (pre-v0.9.10)",
1056 ));
1057 } else {
1058 let use_ids: std::collections::HashSet<&str> =
1059 bundle.uses.iter().map(|u| u.use_id.as_str()).collect();
1060 let mut violations: Vec<String> = Vec::new();
1061 let mut bound_count = 0usize;
1062 for (artifact_id, env_bytes) in &bundle.action_envelopes {
1063 let env = match Envelope::from_json(env_bytes) {
1064 Ok(e) => e,
1065 Err(_) => {
1066 violations.push(format!("action {artifact_id} envelope unparseable"));
1067 continue;
1068 }
1069 };
1070 let derived = match env.payload_bytes() {
1078 Ok(p) => artifact_id_from_pae(&pae(&env.payload_type, &p)),
1079 Err(_) => {
1080 violations
1081 .push(format!("action {artifact_id} envelope payload undecodable"));
1082 continue;
1083 }
1084 };
1085 if &derived != artifact_id {
1086 violations.push(format!(
1087 "action {artifact_id} envelope content derives to {derived} -- envelope substituted or tampered",
1088 ));
1089 continue;
1090 }
1091 let action: ActionStatement = match env.unmarshal_statement() {
1092 Ok(a) => a,
1093 Err(_) => {
1094 violations.push(format!("action {artifact_id} not an ActionStatement"));
1095 continue;
1096 }
1097 };
1098 let raw_nonce = match action.approval_nonce.as_deref() {
1099 Some(n) => n,
1100 None => continue,
1101 };
1102 let claimed_use_id = action
1103 .meta
1104 .as_ref()
1105 .and_then(|m| m.get("approval_use_id"))
1106 .and_then(|v| v.as_str());
1107 let Some(claimed_use_id) = claimed_use_id else {
1108 violations.push(format!(
1109 "action {artifact_id} consumed an approval but its meta has no approval_use_id"
1110 ));
1111 continue;
1112 };
1113 if !use_ids.contains(claimed_use_id) {
1114 violations.push(format!(
1115 "action {artifact_id} claims approval_use_id={} but no such use is embedded",
1116 claimed_use_id,
1117 ));
1118 continue;
1119 }
1120 let expected = nonce_digest(raw_nonce);
1121 let matched_use = bundle.uses.iter().find(|u| u.use_id == claimed_use_id);
1122 if let Some(u) = matched_use {
1123 if u.nonce_digest != expected {
1124 violations.push(format!(
1125 "action {artifact_id} approval_nonce hashes to {} but use {} stores nonce_digest {}",
1126 expected, claimed_use_id, u.nonce_digest,
1127 ));
1128 continue;
1129 }
1130 }
1131 bound_count += 1;
1132 }
1133 if violations.is_empty() {
1134 checks.push(VerifyCheck::pass(
1135 "approval-use-action-binding",
1136 &format!(
1137 "{bound_count} consuming action(s) bind cleanly to content-addressed envelope(s)",
1138 ),
1139 ));
1140 } else {
1141 checks.push(VerifyCheck::fail(
1142 "approval-use-action-binding",
1143 &violations.join("; "),
1144 ));
1145 }
1146 }
1147 }
1148
1149 if !bundle.uses.is_empty() || !bundle.checkpoints.is_empty() {
1176 use std::collections::{HashMap, HashSet};
1177 struct Node<'a> {
1180 label: String,
1181 digest: &'a str,
1182 prev: &'a str,
1183 }
1184 let mut nodes: Vec<Node> = Vec::new();
1185 for u in &bundle.uses {
1186 nodes.push(Node {
1187 label: format!("use {}", u.use_id),
1188 digest: u.record_digest.as_str(),
1189 prev: u.previous_record_digest.as_str(),
1190 });
1191 }
1192 for cp in &bundle.checkpoints {
1193 nodes.push(Node {
1194 label: format!("checkpoint {}", cp.checkpoint_id),
1195 digest: cp.record_digest.as_str(),
1196 prev: cp.previous_record_digest.as_str(),
1197 });
1198 }
1199
1200 let owned: HashSet<&str> = std::iter::once("")
1201 .chain(nodes.iter().map(|n| n.digest))
1202 .collect();
1203
1204 let mut violations: Vec<String> = Vec::new();
1205 for n in &nodes {
1207 if !owned.contains(n.prev) {
1208 violations.push(format!(
1209 "{} previous_record_digest {} not anchored in package",
1210 n.label, n.prev,
1211 ));
1212 }
1213 }
1214 let genesis: Vec<&Node> = nodes.iter().filter(|n| n.prev.is_empty()).collect();
1216 if genesis.len() > 1 {
1217 violations.push(format!(
1218 "{} records claim previous_record_digest='' (genesis): {}",
1219 genesis.len(),
1220 genesis
1221 .iter()
1222 .map(|n| n.label.clone())
1223 .collect::<Vec<_>>()
1224 .join(", "),
1225 ));
1226 }
1227 let mut by_prev: HashMap<&str, Vec<&Node>> = HashMap::new();
1229 for n in &nodes {
1230 by_prev.entry(n.prev).or_default().push(n);
1231 }
1232 for (prev, group) in &by_prev {
1233 if group.len() > 1 && !prev.is_empty() {
1234 violations.push(format!(
1235 "fork: {} records share previous_record_digest {}: {}",
1236 group.len(),
1237 prev,
1238 group
1239 .iter()
1240 .map(|n| n.label.clone())
1241 .collect::<Vec<_>>()
1242 .join(", "),
1243 ));
1244 }
1245 }
1246
1247 if violations.is_empty() {
1250 let by_digest: HashMap<&str, &Node> = nodes.iter().map(|n| (n.digest, n)).collect();
1251 let next_of: HashMap<&str, &Node> = nodes
1252 .iter()
1253 .filter(|n| !n.prev.is_empty())
1254 .map(|n| (n.prev, *(&n)))
1255 .collect();
1256 let start = match genesis.first() {
1257 Some(g) => Some(*g),
1258 None => None,
1259 };
1260 let mut visited: HashSet<&str> = HashSet::new();
1261 let mut current = start;
1262 while let Some(node) = current {
1263 if !visited.insert(node.digest) {
1264 violations.push(format!(
1265 "cycle detected at {} (record_digest {})",
1266 node.label, node.digest,
1267 ));
1268 break;
1269 }
1270 current = next_of.get(node.digest).copied();
1271 }
1272 if violations.is_empty() && visited.len() != nodes.len() {
1274 let unreached: Vec<String> = nodes
1275 .iter()
1276 .filter(|n| !visited.contains(n.digest))
1277 .map(|n| n.label.clone())
1278 .collect();
1279 if !unreached.is_empty() {
1280 violations.push(format!(
1281 "disconnected subchain: {} record(s) not reachable from genesis: {}",
1282 unreached.len(),
1283 unreached.join(", "),
1284 ));
1285 }
1286 }
1287 let _ = by_digest; }
1289
1290 if violations.is_empty() {
1291 checks.push(VerifyCheck::pass(
1292 "approval-use-chain-continuity",
1293 &format!(
1294 "{} record(s) form a single connected linked list from one genesis with no cycles or forks",
1295 nodes.len(),
1296 ),
1297 ));
1298 } else {
1299 checks.push(VerifyCheck::fail(
1300 "approval-use-chain-continuity",
1301 &violations.join("; "),
1302 ));
1303 }
1304 }
1305
1306 let hub_checkpoints: Vec<&JournalCheckpoint> = bundle
1320 .checkpoints
1321 .iter()
1322 .filter(|cp| cp.checkpoint_kind == crate::statements::CheckpointKind::HubOrg)
1323 .collect();
1324 if !hub_checkpoints.is_empty() {
1325 let mut all_ok = true;
1326 let mut details: Vec<String> = Vec::new();
1327 let mut have_valid_signature = false;
1328 let mut security_fatal = false;
1337
1338 for cp in &hub_checkpoints {
1339 match crate::statements::verify_hub_checkpoint_signature(cp, trust) {
1340 crate::statements::HubCheckpointVerification::Valid => {
1341 have_valid_signature = true;
1342 let covered: std::collections::HashSet<&String> =
1347 cp.covered_use_ids.iter().collect();
1348 let missing: Vec<String> = bundle
1349 .uses
1350 .iter()
1351 .filter(|u| !covered.contains(&u.use_id))
1352 .map(|u| u.use_id.clone())
1353 .collect();
1354 if missing.is_empty() {
1355 details.push(format!(
1356 "{} signed by {} verifies; covers {} use(s)",
1357 cp.checkpoint_id,
1358 cp.hub_id,
1359 cp.covered_use_ids.len(),
1360 ));
1361 } else {
1362 all_ok = false;
1363 details.push(format!(
1364 "{} verifies but does not cover {} use(s): {}",
1365 cp.checkpoint_id,
1366 missing.len(),
1367 missing.join(", "),
1368 ));
1369 }
1370 }
1371 crate::statements::HubCheckpointVerification::MissingFields(field) => {
1372 all_ok = false;
1373 details.push(format!(
1374 "{} declares kind=hub-org but field `{}` is missing",
1375 cp.checkpoint_id, field,
1376 ));
1377 }
1378 crate::statements::HubCheckpointVerification::Tampered => {
1379 all_ok = false;
1380 security_fatal = true;
1381 details.push(format!(
1382 "{} hub signature failed verification (tampered or wrong key)",
1383 cp.checkpoint_id,
1384 ));
1385 }
1386 crate::statements::HubCheckpointVerification::NotHubKind => {
1387 all_ok = false;
1391 security_fatal = true;
1392 details.push(format!(
1393 "{} kind toggled out of hub-org during verify",
1394 cp.checkpoint_id,
1395 ));
1396 }
1397 crate::statements::HubCheckpointVerification::UntrustedIssuer => {
1398 all_ok = false;
1399 security_fatal = true;
1400 details.push(format!(
1401 "{} hub_public_key is not a trusted root (configure via `treeship trust add`)",
1402 cp.checkpoint_id,
1403 ));
1404 }
1405 }
1406 }
1407 if all_ok && have_valid_signature {
1408 checks.push(VerifyCheck::pass("replay-hub-org", &details.join("; ")));
1409 } else if security_fatal {
1410 checks.push(VerifyCheck::fail("replay-hub-org", &details.join("; ")));
1414 } else {
1415 checks.push(VerifyCheck::warn("replay-hub-org", &details.join("; ")));
1420 }
1421 }
1422 let _ = ReplayCheckLevel::HubOrg;
1426 let _ = approval_revocation_record_digest as fn(&ApprovalRevocation) -> String;
1427 let _ = ReplayCheck::not_performed;
1428}
1429
1430#[derive(Debug, Clone)]
1432pub struct VerifyCheck {
1433 pub name: String,
1434 pub status: VerifyStatus,
1435 pub detail: String,
1436}
1437
1438#[derive(Debug, Clone, PartialEq, Eq)]
1440pub enum VerifyStatus {
1441 Pass,
1442 Fail,
1443 Warn,
1444}
1445
1446impl VerifyCheck {
1447 pub fn pass(name: &str, detail: &str) -> Self {
1448 Self {
1449 name: name.into(),
1450 status: VerifyStatus::Pass,
1451 detail: detail.into(),
1452 }
1453 }
1454 pub fn fail(name: &str, detail: &str) -> Self {
1455 Self {
1456 name: name.into(),
1457 status: VerifyStatus::Fail,
1458 detail: detail.into(),
1459 }
1460 }
1461 pub fn warn(name: &str, detail: &str) -> Self {
1462 Self {
1463 name: name.into(),
1464 status: VerifyStatus::Warn,
1465 detail: detail.into(),
1466 }
1467 }
1468}
1469
1470impl VerifyCheck {
1471 pub fn passed(&self) -> bool {
1472 self.status == VerifyStatus::Pass
1473 }
1474}
1475
1476const PREVIEW_TEMPLATE: &str = include_str!("preview_template.html");
1479
1480pub fn render_preview_html(receipt: &SessionReceipt) -> String {
1487 let receipt_json = serde_json::to_string_pretty(receipt).unwrap_or_else(|_| "{}".to_string());
1488 let safe_json = receipt_json.replace('<', r"\u003c");
1496
1497 PREVIEW_TEMPLATE.replacen("__RECEIPT_JSON__", &safe_json, 1)
1504}
1505
1506#[cfg(test)]
1507mod tests {
1508 use super::*;
1509 use crate::session::event::*;
1510 use crate::session::manifest::SessionManifest;
1511 use crate::session::receipt::{ArtifactEntry, ReceiptComposer};
1512
1513 fn make_receipt() -> SessionReceipt {
1514 let manifest = SessionManifest::new(
1515 "ssn_pkg_test".into(),
1516 "agent://test".into(),
1517 "2026-04-05T08:00:00Z".into(),
1518 1743843600000,
1519 );
1520
1521 let mk = |seq: u64, inst: &str, et: EventType| -> SessionEvent {
1522 SessionEvent {
1523 session_id: "ssn_pkg_test".into(),
1524 event_id: format!("evt_{:016x}", seq),
1525 timestamp: format!("2026-04-05T08:{:02}:00Z", seq),
1526 sequence_no: seq,
1527 trace_id: "trace_1".into(),
1528 span_id: format!("span_{seq}"),
1529 parent_span_id: None,
1530 agent_id: format!("agent://{inst}"),
1531 agent_instance_id: inst.into(),
1532 agent_name: inst.into(),
1533 agent_role: None,
1534 host_id: "host_1".into(),
1535 tool_runtime_id: None,
1536 event_type: et,
1537 artifact_ref: None,
1538 meta: None,
1539 }
1540 };
1541
1542 let events = vec![
1543 mk(0, "root", EventType::SessionStarted),
1544 mk(
1545 1,
1546 "root",
1547 EventType::AgentStarted {
1548 parent_agent_instance_id: None,
1549 },
1550 ),
1551 mk(
1552 2,
1553 "root",
1554 EventType::AgentCalledTool {
1555 tool_name: "read_file".into(),
1556 tool_input_digest: None,
1557 tool_output_digest: None,
1558 duration_ms: Some(10),
1559 },
1560 ),
1561 mk(
1562 3,
1563 "root",
1564 EventType::AgentCompleted {
1565 termination_reason: None,
1566 },
1567 ),
1568 mk(
1569 4,
1570 "root",
1571 EventType::SessionClosed {
1572 summary: Some("Done".into()),
1573 duration_ms: Some(60000),
1574 },
1575 ),
1576 ];
1577
1578 let artifacts = vec![ArtifactEntry {
1579 artifact_id: "art_001".into(),
1580 payload_type: "action".into(),
1581 digest: None,
1582 signed_at: None,
1583 }];
1584
1585 ReceiptComposer::compose(&manifest, &events, artifacts)
1586 }
1587
1588 #[test]
1589 fn build_and_read_package() {
1590 let receipt = make_receipt();
1591 let tmp = std::env::temp_dir().join(format!("treeship-pkg-test-{}", rand::random::<u32>()));
1592
1593 let output = build_package(&receipt, &tmp).unwrap();
1594 assert!(output.path.exists());
1595 assert!(output.path.join("receipt.json").exists());
1596 assert!(output.path.join("merkle.json").exists());
1597 assert!(output.path.join("render.json").exists());
1598 assert!(output.path.join("preview.html").exists());
1599 assert!(output.receipt_digest.starts_with("sha256:"));
1600 assert!(output.file_count >= 4);
1601
1602 let read_back = read_package(&output.path).unwrap();
1604 assert_eq!(read_back.session.id, "ssn_pkg_test");
1605 assert_eq!(read_back.type_, RECEIPT_TYPE);
1606
1607 let _ = std::fs::remove_dir_all(&tmp);
1608 }
1609
1610 #[test]
1611 fn verify_valid_package() {
1612 let receipt = make_receipt();
1613 let tmp =
1614 std::env::temp_dir().join(format!("treeship-pkg-verify-{}", rand::random::<u32>()));
1615
1616 let output = build_package(&receipt, &tmp).unwrap();
1617 let checks = verify_package(&output.path).unwrap();
1618
1619 let fails: Vec<_> = checks
1620 .iter()
1621 .filter(|c| c.status == VerifyStatus::Fail)
1622 .collect();
1623 assert!(fails.is_empty(), "unexpected failures: {fails:?}");
1624
1625 let passes: Vec<_> = checks
1626 .iter()
1627 .filter(|c| c.status == VerifyStatus::Pass)
1628 .collect();
1629 assert!(
1630 passes.len() >= 5,
1631 "expected at least 5 pass checks, got {}",
1632 passes.len()
1633 );
1634
1635 let _ = std::fs::remove_dir_all(&tmp);
1636 }
1637
1638 #[test]
1642 fn verify_warns_when_reconcile_degraded() {
1643 let mut receipt = make_receipt();
1644 receipt.proofs.reconcile_degraded = true;
1645 let tmp =
1646 std::env::temp_dir().join(format!("treeship-pkg-degraded-{}", rand::random::<u32>()));
1647
1648 let output = build_package(&receipt, &tmp).unwrap();
1649 let checks = verify_package(&output.path).unwrap();
1650
1651 let warned = checks
1652 .iter()
1653 .any(|c| c.name == "reconcile_degraded" && c.status == VerifyStatus::Warn);
1654 assert!(warned, "expected a reconcile_degraded WARN, got {checks:?}");
1655 let fails: Vec<_> = checks
1657 .iter()
1658 .filter(|c| c.status == VerifyStatus::Fail)
1659 .collect();
1660 assert!(fails.is_empty(), "must not hard-fail: {fails:?}");
1661
1662 let _ = std::fs::remove_dir_all(&tmp);
1663 }
1664
1665 #[test]
1666 fn verify_no_degraded_warn_when_clean() {
1667 let receipt = make_receipt();
1669 let tmp =
1670 std::env::temp_dir().join(format!("treeship-pkg-clean-{}", rand::random::<u32>()));
1671 let output = build_package(&receipt, &tmp).unwrap();
1672 let checks = verify_package(&output.path).unwrap();
1673 assert!(
1674 !checks.iter().any(|c| c.name == "reconcile_degraded"),
1675 "clean receipt must not emit a reconcile_degraded check"
1676 );
1677 let _ = std::fs::remove_dir_all(&tmp);
1678 }
1679
1680 #[test]
1681 fn verify_detects_missing_receipt() {
1682 let tmp =
1683 std::env::temp_dir().join(format!("treeship-pkg-empty-{}", rand::random::<u32>()));
1684 std::fs::create_dir_all(&tmp).unwrap();
1685
1686 let err = read_package(&tmp);
1687 assert!(err.is_err());
1688
1689 let _ = std::fs::remove_dir_all(&tmp);
1690 }
1691
1692 #[test]
1693 fn preview_html_contains_session_info() {
1694 let receipt = make_receipt();
1695 let html = render_preview_html(&receipt);
1696 assert!(html.contains("ssn_pkg_test"));
1697 assert!(html.contains("treeship.dev"));
1698 assert!(html.contains("Timeline"));
1699
1700 assert!(
1710 html.contains("'__RECEIPT'+'_JSON__'"),
1711 "JS placeholder check was clobbered by the receipt substitution",
1712 );
1713 assert!(
1714 !html.contains("application/json\">__RECEIPT_JSON__</script>"),
1715 "data slot was not substituted with the receipt JSON",
1716 );
1717 assert_eq!(
1721 html.matches("__RECEIPT_JSON__").count(),
1722 0,
1723 "no raw placeholder token should remain after substitution",
1724 );
1725 }
1726}