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