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 ApprovalRevocation, ApprovalUse, JournalCheckpoint,
20 ReplayCheck, ReplayCheckLevel,
21 approval_revocation_record_digest, approval_use_record_digest,
22 journal_checkpoint_record_digest,
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 { Self::Io(e) }
46}
47impl From<serde_json::Error> for PackageError {
48 fn from(e: serde_json::Error) -> Self { Self::Json(e) }
49}
50
51const RECEIPT_FILE: &str = "receipt.json";
53const MERKLE_FILE: &str = "merkle.json";
54const RENDER_FILE: &str = "render.json";
55const ARTIFACTS_DIR: &str = "artifacts";
56const PROOFS_DIR: &str = "proofs";
57const PREVIEW_FILE: &str = "preview.html";
58
59const APPROVALS_DIR: &str = "approvals";
72const APPROVALS_GRANTS: &str = "approvals/grants";
73const APPROVALS_USES: &str = "approvals/uses";
74const APPROVALS_CHECKPOINTS:&str = "approvals/checkpoints";
75const APPROVALS_INDEX_FILE: &str = "approvals/index.json";
76
77#[derive(Debug, Clone, Default)]
87pub struct ApprovalsBundle {
88 pub grants: Vec<(String, Vec<u8>)>,
93 pub uses: Vec<ApprovalUse>,
97 pub checkpoints: Vec<JournalCheckpoint>,
100 pub revocations: Vec<ApprovalRevocation>,
105
106 pub action_envelopes: Vec<(String, Vec<u8>)>,
114}
115
116#[derive(Debug, Clone, Serialize, Deserialize)]
120pub struct ApprovalsIndex {
121 #[serde(rename = "type")]
123 pub type_: String,
124 pub schema_version: u32,
125 pub grants: Vec<String>,
128 pub uses: Vec<String>,
130 pub checkpoints: Vec<String>,
131 pub revocations: Vec<String>,
132}
133
134impl ApprovalsIndex {
135 pub fn type_string() -> &'static str { "treeship/approvals-index/v1" }
136}
137
138pub struct PackageOutput {
140 pub path: PathBuf,
142 pub receipt_digest: String,
144 pub merkle_root: Option<String>,
146 pub file_count: usize,
148}
149
150pub fn build_package(
160 receipt: &SessionReceipt,
161 output_dir: &Path,
162) -> Result<PackageOutput, PackageError> {
163 build_package_with_approvals(receipt, output_dir, None)
164}
165
166pub fn build_package_with_approvals(
170 receipt: &SessionReceipt,
171 output_dir: &Path,
172 bundle: Option<&ApprovalsBundle>,
173) -> Result<PackageOutput, PackageError> {
174 let session_id = &receipt.session.id;
175 let pkg_dir = output_dir.join(format!("{session_id}.treeship"));
176
177 std::fs::create_dir_all(&pkg_dir)?;
178 std::fs::create_dir_all(pkg_dir.join(ARTIFACTS_DIR))?;
179 std::fs::create_dir_all(pkg_dir.join(PROOFS_DIR))?;
180
181 let mut file_count = 0usize;
182
183 let receipt_bytes = serde_json::to_vec_pretty(receipt)?;
185 std::fs::write(pkg_dir.join(RECEIPT_FILE), &receipt_bytes)?;
186 file_count += 1;
187
188 let receipt_hash = Sha256::digest(&receipt_bytes);
189 let receipt_digest = format!("sha256:{}", hex::encode(receipt_hash));
190
191 let merkle_bytes = serde_json::to_vec_pretty(&receipt.merkle)?;
193 std::fs::write(pkg_dir.join(MERKLE_FILE), &merkle_bytes)?;
194 file_count += 1;
195
196 let render_bytes = serde_json::to_vec_pretty(&receipt.render)?;
198 std::fs::write(pkg_dir.join(RENDER_FILE), &render_bytes)?;
199 file_count += 1;
200
201 for proof_entry in &receipt.merkle.inclusion_proofs {
203 let proof_bytes = serde_json::to_vec_pretty(proof_entry)?;
204 let filename = format!("{}.proof.json", proof_entry.artifact_id);
205 std::fs::write(pkg_dir.join(PROOFS_DIR).join(filename), &proof_bytes)?;
206 file_count += 1;
207 }
208
209 if receipt.render.generate_preview {
211 let preview = render_preview_html(receipt);
212 std::fs::write(pkg_dir.join(PREVIEW_FILE), preview.as_bytes())?;
213 file_count += 1;
214 }
215
216 if let Some(b) = bundle {
221 if !b.grants.is_empty() || !b.uses.is_empty() || !b.checkpoints.is_empty() || !b.revocations.is_empty() || !b.action_envelopes.is_empty() {
222 std::fs::create_dir_all(pkg_dir.join(APPROVALS_GRANTS))?;
223 std::fs::create_dir_all(pkg_dir.join(APPROVALS_USES))?;
224 std::fs::create_dir_all(pkg_dir.join(APPROVALS_CHECKPOINTS))?;
225 std::fs::create_dir_all(pkg_dir.join(ARTIFACTS_DIR))?;
231 for (artifact_id, envelope_bytes) in &b.action_envelopes {
232 let safe = sanitize_filename(artifact_id);
233 std::fs::write(
234 pkg_dir.join(ARTIFACTS_DIR).join(format!("{safe}.json")),
235 envelope_bytes,
236 )?;
237 file_count += 1;
238 }
239
240 let mut grant_ids = Vec::with_capacity(b.grants.len());
241 for (grant_id, envelope_bytes) in &b.grants {
242 let safe = sanitize_filename(grant_id);
243 std::fs::write(
244 pkg_dir.join(APPROVALS_GRANTS).join(format!("{safe}.json")),
245 envelope_bytes,
246 )?;
247 grant_ids.push(grant_id.clone());
248 file_count += 1;
249 }
250
251 let mut use_ids = Vec::with_capacity(b.uses.len());
252 for u in &b.uses {
253 let safe = sanitize_filename(&u.use_id);
254 let bytes = serde_json::to_vec_pretty(u)?;
255 std::fs::write(
256 pkg_dir.join(APPROVALS_USES).join(format!("{safe}.json")),
257 &bytes,
258 )?;
259 use_ids.push(u.use_id.clone());
260 file_count += 1;
261 }
262
263 let mut checkpoint_ids = Vec::with_capacity(b.checkpoints.len());
264 for cp in &b.checkpoints {
265 let safe = sanitize_filename(&cp.checkpoint_id);
266 let bytes = serde_json::to_vec_pretty(cp)?;
267 std::fs::write(
268 pkg_dir.join(APPROVALS_CHECKPOINTS).join(format!("{safe}.json")),
269 &bytes,
270 )?;
271 checkpoint_ids.push(cp.checkpoint_id.clone());
272 file_count += 1;
273 }
274
275 let mut revocation_ids = Vec::with_capacity(b.revocations.len());
276 for rev in &b.revocations {
277 let safe = sanitize_filename(&rev.revocation_id);
278 let bytes = serde_json::to_vec_pretty(rev)?;
279 std::fs::write(
280 pkg_dir.join(APPROVALS_DIR).join(format!("revocations-{safe}.json")),
281 &bytes,
282 )?;
283 revocation_ids.push(rev.revocation_id.clone());
284 file_count += 1;
285 }
286
287 let index = ApprovalsIndex {
288 type_: ApprovalsIndex::type_string().into(),
289 schema_version: 1,
290 grants: grant_ids,
291 uses: use_ids,
292 checkpoints: checkpoint_ids,
293 revocations: revocation_ids,
294 };
295 let index_bytes = serde_json::to_vec_pretty(&index)?;
296 std::fs::write(pkg_dir.join(APPROVALS_INDEX_FILE), &index_bytes)?;
297 file_count += 1;
298 }
299 }
300
301 Ok(PackageOutput {
302 path: pkg_dir,
303 receipt_digest,
304 merkle_root: receipt.merkle.root.clone(),
305 file_count,
306 })
307}
308
309fn sanitize_filename(s: &str) -> String {
313 s.chars()
314 .map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '.' || c == '_' { c } else { '_' })
315 .collect()
316}
317
318pub fn read_approvals_bundle(pkg_dir: &Path) -> Result<ApprovalsBundle, PackageError> {
328 let approvals_dir = pkg_dir.join(APPROVALS_DIR);
329 if !approvals_dir.is_dir() {
330 return Ok(ApprovalsBundle::default());
331 }
332
333 let mut bundle = ApprovalsBundle::default();
334
335 let grants_dir = pkg_dir.join(APPROVALS_GRANTS);
338 if grants_dir.is_dir() {
339 for entry in std::fs::read_dir(&grants_dir)? {
340 let entry = entry?;
341 let path = entry.path();
342 if path.extension().and_then(|s| s.to_str()) != Some("json") { continue; }
343 let id = path.file_stem().and_then(|s| s.to_str()).unwrap_or("").to_string();
344 let bytes = std::fs::read(&path)?;
345 bundle.grants.push((id, bytes));
346 }
347 }
348
349 let uses_dir = pkg_dir.join(APPROVALS_USES);
350 if uses_dir.is_dir() {
351 for entry in std::fs::read_dir(&uses_dir)? {
352 let entry = entry?;
353 let path = entry.path();
354 if path.extension().and_then(|s| s.to_str()) != Some("json") { continue; }
355 let bytes = std::fs::read(&path)?;
356 let u: ApprovalUse = serde_json::from_slice(&bytes)?;
357 bundle.uses.push(u);
358 }
359 }
360
361 let cps_dir = pkg_dir.join(APPROVALS_CHECKPOINTS);
362 if cps_dir.is_dir() {
363 for entry in std::fs::read_dir(&cps_dir)? {
364 let entry = entry?;
365 let path = entry.path();
366 if path.extension().and_then(|s| s.to_str()) != Some("json") { continue; }
367 let bytes = std::fs::read(&path)?;
368 let cp: JournalCheckpoint = serde_json::from_slice(&bytes)?;
369 bundle.checkpoints.push(cp);
370 }
371 }
372
373 let arts_dir = pkg_dir.join(ARTIFACTS_DIR);
380 if arts_dir.is_dir() {
381 for entry in std::fs::read_dir(&arts_dir)? {
382 let entry = entry?;
383 let path = entry.path();
384 if path.extension().and_then(|s| s.to_str()) != Some("json") { continue; }
385 let id = path.file_stem().and_then(|s| s.to_str()).unwrap_or("").to_string();
386 let bytes = std::fs::read(&path)?;
387 bundle.action_envelopes.push((id, bytes));
388 }
389 }
390
391 Ok(bundle)
392}
393
394pub fn read_package(pkg_dir: &Path) -> Result<SessionReceipt, PackageError> {
396 let receipt_path = pkg_dir.join(RECEIPT_FILE);
397 if !receipt_path.exists() {
398 return Err(PackageError::InvalidPackage(
399 format!("missing {RECEIPT_FILE} in {}", pkg_dir.display()),
400 ));
401 }
402 let bytes = std::fs::read(&receipt_path)?;
403 let receipt: SessionReceipt = serde_json::from_slice(&bytes)?;
404
405 if receipt.type_ != RECEIPT_TYPE {
406 return Err(PackageError::InvalidPackage(
407 format!("unexpected type: {} (expected {RECEIPT_TYPE})", receipt.type_),
408 ));
409 }
410
411 Ok(receipt)
412}
413
414pub fn verify_package(pkg_dir: &Path) -> Result<Vec<VerifyCheck>, PackageError> {
432 let trust = match crate::trust::TrustRootStore::open_default_or_empty() {
433 Ok(t) => t,
434 Err(e) => {
435 return Ok(vec![VerifyCheck::fail(
439 "trust-root",
440 &format!("trust store unreadable: {e}"),
441 )]);
442 }
443 };
444 verify_package_with_trust(pkg_dir, &trust)
445}
446
447pub fn verify_package_with_trust(
451 pkg_dir: &Path,
452 trust: &crate::trust::TrustRootStore,
453) -> Result<Vec<VerifyCheck>, PackageError> {
454 let mut checks = Vec::new();
455
456 let receipt = match read_package(pkg_dir) {
458 Ok(r) => {
459 checks.push(VerifyCheck::pass("receipt.json", "Parses as valid Session Receipt"));
460 r
461 }
462 Err(e) => {
463 checks.push(VerifyCheck::fail("receipt.json", &format!("Failed to parse: {e}")));
464 return Ok(checks);
465 }
466 };
467
468 if receipt.type_ == RECEIPT_TYPE {
470 checks.push(VerifyCheck::pass("type", "Correct receipt type"));
471 } else {
472 checks.push(VerifyCheck::fail("type", &format!("Expected {RECEIPT_TYPE}, got {}", receipt.type_)));
473 }
474
475 let receipt_path = pkg_dir.join(RECEIPT_FILE);
490 let on_disk = std::fs::read(&receipt_path)?;
491 let re_serialized = serde_json::to_vec_pretty(&receipt)?;
492 if on_disk == re_serialized {
493 checks.push(VerifyCheck::pass("determinism", "receipt.json round-trips identically (structural, NOT a signature)"));
494 } else {
495 checks.push(VerifyCheck::warn("determinism", "receipt.json does not byte-match after re-serialization"));
497 }
498
499 checks.push(VerifyCheck::warn(
506 "receipt_body_binding",
507 "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).",
508 ));
509
510 if !receipt.artifacts.is_empty() {
512 let version = receipt.merkle.merkle_version;
519 let mut tree = match crate::merkle::MerkleTree::with_version(version) {
520 Ok(t) => t,
521 Err(e) => {
522 checks.push(VerifyCheck::fail(
523 "merkle_root",
524 &format!("receipt declared unknown merkle_version: {e}"),
525 ));
526 return Ok(finish_package_checks(checks, &receipt));
529 }
530 };
531 for art in &receipt.artifacts {
532 tree.append(&art.artifact_id);
533 }
534 let root_bytes = tree.root();
535 let recomputed_root = root_bytes
536 .map(|r| format!("mroot_{}", hex::encode(r)));
537 let root_hex = root_bytes
538 .map(|r| hex::encode(r))
539 .unwrap_or_default();
540
541 if recomputed_root == receipt.merkle.root {
542 checks.push(VerifyCheck::pass("merkle_root", "Merkle root matches recomputed value"));
543 } else {
544 checks.push(VerifyCheck::fail(
545 "merkle_root",
546 &format!(
547 "Mismatch: on-disk {:?} vs recomputed {:?}",
548 receipt.merkle.root, recomputed_root
549 ),
550 ));
551 }
552
553 for proof_entry in &receipt.merkle.inclusion_proofs {
558 if proof_entry.proof.merkle_version != version {
559 checks.push(VerifyCheck::fail(
560 &format!("inclusion:{}", proof_entry.artifact_id),
561 &format!(
562 "proof merkle_version {} != receipt section v{}",
563 proof_entry.proof.merkle_version, version,
564 ),
565 ));
566 continue;
567 }
568 let verified = crate::merkle::MerkleTree::verify_proof(
569 version,
570 &root_hex,
571 &proof_entry.artifact_id,
572 &proof_entry.proof,
573 );
574 if verified {
575 checks.push(VerifyCheck::pass(
576 &format!("inclusion:{}", proof_entry.artifact_id),
577 "Inclusion proof valid",
578 ));
579 } else {
580 checks.push(VerifyCheck::fail(
581 &format!("inclusion:{}", proof_entry.artifact_id),
582 "Inclusion proof failed verification",
583 ));
584 }
585 }
586 } else {
587 checks.push(VerifyCheck::warn("merkle_root", "No artifacts to verify"));
588 }
589
590 if receipt.merkle.leaf_count == receipt.artifacts.len() {
592 checks.push(VerifyCheck::pass("leaf_count", "Leaf count matches artifact count"));
593 } else {
594 checks.push(VerifyCheck::fail(
595 "leaf_count",
596 &format!("leaf_count {} != artifact count {}", receipt.merkle.leaf_count, receipt.artifacts.len()),
597 ));
598 }
599
600 let ordered = receipt.timeline.windows(2).all(|w| {
602 (&w[0].timestamp, w[0].sequence_no, &w[0].event_id)
603 <= (&w[1].timestamp, w[1].sequence_no, &w[1].event_id)
604 });
605 if ordered {
606 checks.push(VerifyCheck::pass("timeline_order", "Timeline is correctly ordered"));
607 } else {
608 checks.push(VerifyCheck::fail("timeline_order", "Timeline entries are not in deterministic order"));
609 }
610
611 if receipt.proofs.event_log_skipped > 0 {
619 checks.push(VerifyCheck::warn(
620 "event_log_completeness",
621 &format!(
622 "{} event(s) skipped during close (malformed lines in events.jsonl). \
623 Receipt is cryptographically valid but does not represent the full event stream. \
624 Inspect close-time stderr or the events.jsonl directly to investigate.",
625 receipt.proofs.event_log_skipped,
626 ),
627 ));
628 }
629
630 if receipt.proofs.reconcile_untracked_truncated > 0 {
631 checks.push(VerifyCheck::warn(
632 "reconcile_completeness",
633 &format!(
634 "untracked git reconcile exceeded cap {} (saw at least {}). \
635 Per-file synthetic events were skipped and the receipt is bounded, not complete for untracked files.",
636 receipt.proofs.reconcile_untracked_cap,
637 receipt.proofs.reconcile_untracked_truncated,
638 ),
639 ));
640 }
641
642 if receipt.proofs.reconcile_degraded {
648 checks.push(VerifyCheck::warn(
649 "reconcile_degraded",
650 "the git reconcile backstop was UNAVAILABLE at session close although git worked at start \
651 (.git removed, corrupt index, or git not on PATH). Files changed outside a captured \
652 AgentWroteFile event may be MISSING from this receipt's file ledger — treat the \
653 \"Files changed\" list as incomplete.",
654 ));
655 }
656
657 let bundle = read_approvals_bundle(pkg_dir).unwrap_or_default();
669 add_approval_evidence_checks(&mut checks, &bundle, trust);
670
671 Ok(checks)
672}
673
674fn finish_package_checks(
679 mut checks: Vec<VerifyCheck>,
680 receipt: &SessionReceipt,
681) -> Vec<VerifyCheck> {
682 if receipt.merkle.leaf_count == receipt.artifacts.len() {
683 checks.push(VerifyCheck::pass("leaf_count", "Leaf count matches artifact count"));
684 } else {
685 checks.push(VerifyCheck::fail(
686 "leaf_count",
687 &format!(
688 "leaf_count {} != artifact count {}",
689 receipt.merkle.leaf_count, receipt.artifacts.len(),
690 ),
691 ));
692 }
693
694 let ordered = receipt.timeline.windows(2).all(|w| {
695 (&w[0].timestamp, w[0].sequence_no, &w[0].event_id)
696 <= (&w[1].timestamp, w[1].sequence_no, &w[1].event_id)
697 });
698 if ordered {
699 checks.push(VerifyCheck::pass("timeline_order", "Timeline is correctly ordered"));
700 } else {
701 checks.push(VerifyCheck::fail("timeline_order", "Timeline entries are not in deterministic order"));
702 }
703
704 checks
705}
706
707pub(crate) fn add_approval_evidence_checks(
718 checks: &mut Vec<VerifyCheck>,
719 bundle: &ApprovalsBundle,
720 trust: &crate::trust::TrustRootStore,
721) {
722 if bundle.uses.is_empty() && bundle.checkpoints.is_empty() {
723 return;
727 }
728
729 use std::collections::HashMap;
738 let mut by_nonce: HashMap<(String, String), Vec<&ApprovalUse>> = HashMap::new();
739 let mut by_use_id: HashMap<&str, Vec<&ApprovalUse>> = HashMap::new();
740 for u in &bundle.uses {
741 by_nonce
742 .entry((u.grant_id.clone(), u.nonce_digest.clone()))
743 .or_default()
744 .push(u);
745 by_use_id.entry(&u.use_id).or_default().push(u);
746 }
747 let over_max: Vec<((String, String), Vec<&ApprovalUse>, u32)> = by_nonce
748 .iter()
749 .filter_map(|(key, uses)| {
750 let max = uses.iter().filter_map(|u| u.max_uses).next()?;
751 if (uses.len() as u32) > max {
752 Some((key.clone(), uses.iter().map(|u| *u).collect(), max))
753 } else {
754 None
755 }
756 })
757 .collect();
758 let dup_use_ids: Vec<(&&str, &Vec<&ApprovalUse>)> =
759 by_use_id.iter().filter(|(_, v)| v.len() > 1).collect();
760
761 if over_max.is_empty() && dup_use_ids.is_empty() {
762 checks.push(VerifyCheck::pass(
763 "replay-package-local",
764 &format!("no duplicate approval use inside package ({} uses scanned)", bundle.uses.len()),
765 ));
766 } else {
767 let mut detail = String::from("package-local replay violation:");
768 for ((grant_id, _nd), uses, max) in &over_max {
769 detail.push_str(&format!(
770 " grant {grant_id} consumed {} times in this package (max_uses={max});",
771 uses.len(),
772 ));
773 }
774 for (uid, uses) in &dup_use_ids {
775 detail.push_str(&format!(" use_id {uid} appears {} times;", uses.len()));
776 }
777 checks.push(VerifyCheck::fail("replay-package-local", &detail));
778 }
779
780 if !bundle.checkpoints.is_empty() {
785 let mut tampered = Vec::new();
786 for cp in &bundle.checkpoints {
787 let recomputed = journal_checkpoint_record_digest(cp);
788 if recomputed != cp.record_digest {
789 tampered.push((cp.checkpoint_id.clone(), cp.record_digest.clone(), recomputed));
790 }
791 }
792 if tampered.is_empty() {
793 checks.push(VerifyCheck::pass(
794 "replay-included-checkpoint",
795 &format!("{} included journal checkpoint(s) verify offline", bundle.checkpoints.len()),
796 ));
797 } else {
798 let detail = tampered.iter()
799 .map(|(id, expected, actual)| {
800 format!("checkpoint {id} tampered (stored {expected}, recomputed {actual})")
801 })
802 .collect::<Vec<_>>()
803 .join("; ");
804 checks.push(VerifyCheck::fail("replay-included-checkpoint", &detail));
805 }
806 }
807
808 let mut tampered_uses = Vec::new();
818 for u in &bundle.uses {
819 let recomputed = approval_use_record_digest(u);
820 if recomputed != u.record_digest {
821 tampered_uses.push((u.use_id.clone(), u.record_digest.clone(), recomputed));
822 }
823 }
824 if !bundle.uses.is_empty() {
825 if tampered_uses.is_empty() {
826 checks.push(VerifyCheck::pass(
827 "approval-use-record-digest",
828 &format!("{} use record(s) recompute identically", bundle.uses.len()),
829 ));
830 } else {
831 let detail = tampered_uses.iter()
832 .map(|(id, expected, actual)| {
833 format!("use {id} tampered (stored {expected}, recomputed {actual})")
834 })
835 .collect::<Vec<_>>()
836 .join("; ");
837 checks.push(VerifyCheck::fail("approval-use-record-digest", &detail));
838 }
839 }
840
841 if !bundle.uses.is_empty() {
859 use crate::attestation::envelope::Envelope;
860 use crate::attestation::{pae, artifact_id_from_pae};
861 use crate::statements::{nonce_digest, ApprovalStatement};
862 let mut grant_nonce_digest: std::collections::HashMap<String, String> = std::collections::HashMap::new();
863 let mut tampered_grants: Vec<String> = Vec::new();
864 for (grant_id, env_bytes) in &bundle.grants {
865 let env = match Envelope::from_json(env_bytes) {
866 Ok(e) => e,
867 Err(_) => {
868 tampered_grants.push(format!("grant {grant_id} envelope unparseable"));
869 continue;
870 }
871 };
872 let derived = match env.payload_bytes() {
877 Ok(p) => artifact_id_from_pae(&pae(&env.payload_type, &p)),
878 Err(_) => {
879 tampered_grants.push(format!("grant {grant_id} envelope payload undecodable"));
880 continue;
881 }
882 };
883 if &derived != grant_id {
884 tampered_grants.push(format!(
885 "grant {grant_id} envelope content derives to {derived} -- envelope substituted or tampered",
886 ));
887 continue;
888 }
889 let approval: ApprovalStatement = match env.unmarshal_statement() {
890 Ok(a) => a,
891 Err(_) => {
892 tampered_grants.push(format!("grant {grant_id} payload not an ApprovalStatement"));
893 continue;
894 }
895 };
896 grant_nonce_digest.insert(grant_id.clone(), nonce_digest(&approval.nonce));
897 }
898 let mut mismatches: Vec<String> = Vec::new();
899 let mut missing_grants: Vec<String> = Vec::new();
900 for u in &bundle.uses {
901 match grant_nonce_digest.get(&u.grant_id) {
902 Some(expected) => {
903 if expected != &u.nonce_digest {
904 mismatches.push(format!(
905 "use {} claims nonce_digest {} but grant {} signed nonce hashes to {}",
906 u.use_id, u.nonce_digest, u.grant_id, expected,
907 ));
908 }
909 }
910 None => {
911 missing_grants.push(format!(
912 "use {} references grant {} but no usable grant envelope is in the package",
913 u.use_id, u.grant_id,
914 ));
915 }
916 }
917 }
918 if mismatches.is_empty() && missing_grants.is_empty() && tampered_grants.is_empty() {
919 checks.push(VerifyCheck::pass(
920 "approval-use-nonce-binding",
921 &format!(
922 "{} use record(s) bind to content-addressed grant signed nonces",
923 bundle.uses.len(),
924 ),
925 ));
926 } else {
927 let mut parts: Vec<String> = Vec::new();
928 if !tampered_grants.is_empty() { parts.push(tampered_grants.join("; ")); }
929 if !mismatches.is_empty() { parts.push(mismatches.join("; ")); }
930 if !missing_grants.is_empty() { parts.push(missing_grants.join("; ")); }
931 checks.push(VerifyCheck::fail("approval-use-nonce-binding", &parts.join("; ")));
932 }
933 }
934
935 if !bundle.uses.is_empty() {
950 use crate::attestation::envelope::Envelope;
951 use crate::attestation::{pae, artifact_id_from_pae};
952 use crate::statements::{nonce_digest, ActionStatement};
953 if bundle.action_envelopes.is_empty() {
954 checks.push(VerifyCheck::warn(
955 "approval-use-action-binding",
956 "no action envelopes embedded -- action↔use binding not asserted by package (pre-v0.9.10)",
957 ));
958 } else {
959 let use_ids: std::collections::HashSet<&str> = bundle.uses.iter().map(|u| u.use_id.as_str()).collect();
960 let mut violations: Vec<String> = Vec::new();
961 let mut bound_count = 0usize;
962 for (artifact_id, env_bytes) in &bundle.action_envelopes {
963 let env = match Envelope::from_json(env_bytes) {
964 Ok(e) => e,
965 Err(_) => {
966 violations.push(format!("action {artifact_id} envelope unparseable"));
967 continue;
968 }
969 };
970 let derived = match env.payload_bytes() {
978 Ok(p) => artifact_id_from_pae(&pae(&env.payload_type, &p)),
979 Err(_) => {
980 violations.push(format!("action {artifact_id} envelope payload undecodable"));
981 continue;
982 }
983 };
984 if &derived != artifact_id {
985 violations.push(format!(
986 "action {artifact_id} envelope content derives to {derived} -- envelope substituted or tampered",
987 ));
988 continue;
989 }
990 let action: ActionStatement = match env.unmarshal_statement() {
991 Ok(a) => a,
992 Err(_) => {
993 violations.push(format!("action {artifact_id} not an ActionStatement"));
994 continue;
995 }
996 };
997 let raw_nonce = match action.approval_nonce.as_deref() {
998 Some(n) => n,
999 None => continue,
1000 };
1001 let claimed_use_id = action
1002 .meta
1003 .as_ref()
1004 .and_then(|m| m.get("approval_use_id"))
1005 .and_then(|v| v.as_str());
1006 let Some(claimed_use_id) = claimed_use_id else {
1007 violations.push(format!(
1008 "action {artifact_id} consumed an approval but its meta has no approval_use_id"
1009 ));
1010 continue;
1011 };
1012 if !use_ids.contains(claimed_use_id) {
1013 violations.push(format!(
1014 "action {artifact_id} claims approval_use_id={} but no such use is embedded",
1015 claimed_use_id,
1016 ));
1017 continue;
1018 }
1019 let expected = nonce_digest(raw_nonce);
1020 let matched_use = bundle.uses.iter().find(|u| u.use_id == claimed_use_id);
1021 if let Some(u) = matched_use {
1022 if u.nonce_digest != expected {
1023 violations.push(format!(
1024 "action {artifact_id} approval_nonce hashes to {} but use {} stores nonce_digest {}",
1025 expected, claimed_use_id, u.nonce_digest,
1026 ));
1027 continue;
1028 }
1029 }
1030 bound_count += 1;
1031 }
1032 if violations.is_empty() {
1033 checks.push(VerifyCheck::pass(
1034 "approval-use-action-binding",
1035 &format!(
1036 "{bound_count} consuming action(s) bind cleanly to content-addressed envelope(s)",
1037 ),
1038 ));
1039 } else {
1040 checks.push(VerifyCheck::fail(
1041 "approval-use-action-binding",
1042 &violations.join("; "),
1043 ));
1044 }
1045 }
1046 }
1047
1048 if !bundle.uses.is_empty() || !bundle.checkpoints.is_empty() {
1075 use std::collections::{HashMap, HashSet};
1076 struct Node<'a> { label: String, digest: &'a str, prev: &'a str }
1079 let mut nodes: Vec<Node> = Vec::new();
1080 for u in &bundle.uses {
1081 nodes.push(Node {
1082 label: format!("use {}", u.use_id),
1083 digest: u.record_digest.as_str(),
1084 prev: u.previous_record_digest.as_str(),
1085 });
1086 }
1087 for cp in &bundle.checkpoints {
1088 nodes.push(Node {
1089 label: format!("checkpoint {}", cp.checkpoint_id),
1090 digest: cp.record_digest.as_str(),
1091 prev: cp.previous_record_digest.as_str(),
1092 });
1093 }
1094
1095 let owned: HashSet<&str> = std::iter::once("")
1096 .chain(nodes.iter().map(|n| n.digest))
1097 .collect();
1098
1099 let mut violations: Vec<String> = Vec::new();
1100 for n in &nodes {
1102 if !owned.contains(n.prev) {
1103 violations.push(format!(
1104 "{} previous_record_digest {} not anchored in package",
1105 n.label, n.prev,
1106 ));
1107 }
1108 }
1109 let genesis: Vec<&Node> = nodes.iter().filter(|n| n.prev.is_empty()).collect();
1111 if genesis.len() > 1 {
1112 violations.push(format!(
1113 "{} records claim previous_record_digest='' (genesis): {}",
1114 genesis.len(),
1115 genesis.iter().map(|n| n.label.clone()).collect::<Vec<_>>().join(", "),
1116 ));
1117 }
1118 let mut by_prev: HashMap<&str, Vec<&Node>> = HashMap::new();
1120 for n in &nodes {
1121 by_prev.entry(n.prev).or_default().push(n);
1122 }
1123 for (prev, group) in &by_prev {
1124 if group.len() > 1 && !prev.is_empty() {
1125 violations.push(format!(
1126 "fork: {} records share previous_record_digest {}: {}",
1127 group.len(),
1128 prev,
1129 group.iter().map(|n| n.label.clone()).collect::<Vec<_>>().join(", "),
1130 ));
1131 }
1132 }
1133
1134 if violations.is_empty() {
1137 let by_digest: HashMap<&str, &Node> = nodes.iter().map(|n| (n.digest, n)).collect();
1138 let next_of: HashMap<&str, &Node> = nodes
1139 .iter()
1140 .filter(|n| !n.prev.is_empty())
1141 .map(|n| (n.prev, *(&n)))
1142 .collect();
1143 let start = match genesis.first() {
1144 Some(g) => Some(*g),
1145 None => None,
1146 };
1147 let mut visited: HashSet<&str> = HashSet::new();
1148 let mut current = start;
1149 while let Some(node) = current {
1150 if !visited.insert(node.digest) {
1151 violations.push(format!(
1152 "cycle detected at {} (record_digest {})",
1153 node.label, node.digest,
1154 ));
1155 break;
1156 }
1157 current = next_of.get(node.digest).copied();
1158 }
1159 if violations.is_empty() && visited.len() != nodes.len() {
1161 let unreached: Vec<String> = nodes.iter()
1162 .filter(|n| !visited.contains(n.digest))
1163 .map(|n| n.label.clone())
1164 .collect();
1165 if !unreached.is_empty() {
1166 violations.push(format!(
1167 "disconnected subchain: {} record(s) not reachable from genesis: {}",
1168 unreached.len(),
1169 unreached.join(", "),
1170 ));
1171 }
1172 }
1173 let _ = by_digest; }
1175
1176 if violations.is_empty() {
1177 checks.push(VerifyCheck::pass(
1178 "approval-use-chain-continuity",
1179 &format!(
1180 "{} record(s) form a single connected linked list from one genesis with no cycles or forks",
1181 nodes.len(),
1182 ),
1183 ));
1184 } else {
1185 checks.push(VerifyCheck::fail(
1186 "approval-use-chain-continuity",
1187 &violations.join("; "),
1188 ));
1189 }
1190 }
1191
1192 let hub_checkpoints: Vec<&JournalCheckpoint> = bundle
1206 .checkpoints
1207 .iter()
1208 .filter(|cp| cp.checkpoint_kind == crate::statements::CheckpointKind::HubOrg)
1209 .collect();
1210 if !hub_checkpoints.is_empty() {
1211 let mut all_ok = true;
1212 let mut details: Vec<String> = Vec::new();
1213 let mut have_valid_signature = false;
1214 let mut security_fatal = false;
1223
1224 for cp in &hub_checkpoints {
1225 match crate::statements::verify_hub_checkpoint_signature(cp, trust) {
1226 crate::statements::HubCheckpointVerification::Valid => {
1227 have_valid_signature = true;
1228 let covered: std::collections::HashSet<&String> =
1233 cp.covered_use_ids.iter().collect();
1234 let missing: Vec<String> = bundle
1235 .uses
1236 .iter()
1237 .filter(|u| !covered.contains(&u.use_id))
1238 .map(|u| u.use_id.clone())
1239 .collect();
1240 if missing.is_empty() {
1241 details.push(format!(
1242 "{} signed by {} verifies; covers {} use(s)",
1243 cp.checkpoint_id,
1244 cp.hub_id,
1245 cp.covered_use_ids.len(),
1246 ));
1247 } else {
1248 all_ok = false;
1249 details.push(format!(
1250 "{} verifies but does not cover {} use(s): {}",
1251 cp.checkpoint_id,
1252 missing.len(),
1253 missing.join(", "),
1254 ));
1255 }
1256 }
1257 crate::statements::HubCheckpointVerification::MissingFields(field) => {
1258 all_ok = false;
1259 details.push(format!(
1260 "{} declares kind=hub-org but field `{}` is missing",
1261 cp.checkpoint_id, field,
1262 ));
1263 }
1264 crate::statements::HubCheckpointVerification::Tampered => {
1265 all_ok = false;
1266 security_fatal = true;
1267 details.push(format!(
1268 "{} hub signature failed verification (tampered or wrong key)",
1269 cp.checkpoint_id,
1270 ));
1271 }
1272 crate::statements::HubCheckpointVerification::NotHubKind => {
1273 all_ok = false;
1277 security_fatal = true;
1278 details.push(format!(
1279 "{} kind toggled out of hub-org during verify",
1280 cp.checkpoint_id,
1281 ));
1282 }
1283 crate::statements::HubCheckpointVerification::UntrustedIssuer => {
1284 all_ok = false;
1285 security_fatal = true;
1286 details.push(format!(
1287 "{} hub_public_key is not a trusted root (configure via `treeship trust add`)",
1288 cp.checkpoint_id,
1289 ));
1290 }
1291 }
1292 }
1293 if all_ok && have_valid_signature {
1294 checks.push(VerifyCheck::pass(
1295 "replay-hub-org",
1296 &details.join("; "),
1297 ));
1298 } else if security_fatal {
1299 checks.push(VerifyCheck::fail(
1303 "replay-hub-org",
1304 &details.join("; "),
1305 ));
1306 } else {
1307 checks.push(VerifyCheck::warn(
1312 "replay-hub-org",
1313 &details.join("; "),
1314 ));
1315 }
1316 }
1317 let _ = ReplayCheckLevel::HubOrg;
1321 let _ = approval_revocation_record_digest as fn(&ApprovalRevocation) -> String;
1322 let _ = ReplayCheck::not_performed;
1323}
1324
1325#[derive(Debug, Clone)]
1327pub struct VerifyCheck {
1328 pub name: String,
1329 pub status: VerifyStatus,
1330 pub detail: String,
1331}
1332
1333#[derive(Debug, Clone, PartialEq, Eq)]
1335pub enum VerifyStatus {
1336 Pass,
1337 Fail,
1338 Warn,
1339}
1340
1341impl VerifyCheck {
1342 pub fn pass(name: &str, detail: &str) -> Self {
1343 Self { name: name.into(), status: VerifyStatus::Pass, detail: detail.into() }
1344 }
1345 pub fn fail(name: &str, detail: &str) -> Self {
1346 Self { name: name.into(), status: VerifyStatus::Fail, detail: detail.into() }
1347 }
1348 pub fn warn(name: &str, detail: &str) -> Self {
1349 Self { name: name.into(), status: VerifyStatus::Warn, detail: detail.into() }
1350 }
1351}
1352
1353impl VerifyCheck {
1354 pub fn passed(&self) -> bool {
1355 self.status == VerifyStatus::Pass
1356 }
1357}
1358
1359const PREVIEW_TEMPLATE: &str = include_str!("preview_template.html");
1362
1363pub fn render_preview_html(receipt: &SessionReceipt) -> String {
1370 let receipt_json = serde_json::to_string_pretty(receipt)
1371 .unwrap_or_else(|_| "{}".to_string());
1372 let safe_json = receipt_json.replace('<', r"\u003c");
1380
1381 PREVIEW_TEMPLATE
1388 .replacen("__RECEIPT_JSON__", &safe_json, 1)
1389}
1390
1391#[cfg(test)]
1392mod tests {
1393 use super::*;
1394 use crate::session::event::*;
1395 use crate::session::manifest::SessionManifest;
1396 use crate::session::receipt::{ArtifactEntry, ReceiptComposer};
1397
1398 fn make_receipt() -> SessionReceipt {
1399 let manifest = SessionManifest::new(
1400 "ssn_pkg_test".into(),
1401 "agent://test".into(),
1402 "2026-04-05T08:00:00Z".into(),
1403 1743843600000,
1404 );
1405
1406 let mk = |seq: u64, inst: &str, et: EventType| -> SessionEvent {
1407 SessionEvent {
1408 session_id: "ssn_pkg_test".into(),
1409 event_id: format!("evt_{:016x}", seq),
1410 timestamp: format!("2026-04-05T08:{:02}:00Z", seq),
1411 sequence_no: seq,
1412 trace_id: "trace_1".into(),
1413 span_id: format!("span_{seq}"),
1414 parent_span_id: None,
1415 agent_id: format!("agent://{inst}"),
1416 agent_instance_id: inst.into(),
1417 agent_name: inst.into(),
1418 agent_role: None,
1419 host_id: "host_1".into(),
1420 tool_runtime_id: None,
1421 event_type: et,
1422 artifact_ref: None,
1423 meta: None,
1424 }
1425 };
1426
1427 let events = vec![
1428 mk(0, "root", EventType::SessionStarted),
1429 mk(1, "root", EventType::AgentStarted { parent_agent_instance_id: None }),
1430 mk(2, "root", EventType::AgentCalledTool {
1431 tool_name: "read_file".into(),
1432 tool_input_digest: None,
1433 tool_output_digest: None,
1434 duration_ms: Some(10),
1435 }),
1436 mk(3, "root", EventType::AgentCompleted { termination_reason: None }),
1437 mk(4, "root", EventType::SessionClosed { summary: Some("Done".into()), duration_ms: Some(60000) }),
1438 ];
1439
1440 let artifacts = vec![
1441 ArtifactEntry { artifact_id: "art_001".into(), payload_type: "action".into(), digest: None, signed_at: None },
1442 ];
1443
1444 ReceiptComposer::compose(&manifest, &events, artifacts)
1445 }
1446
1447 #[test]
1448 fn build_and_read_package() {
1449 let receipt = make_receipt();
1450 let tmp = std::env::temp_dir().join(format!("treeship-pkg-test-{}", rand::random::<u32>()));
1451
1452 let output = build_package(&receipt, &tmp).unwrap();
1453 assert!(output.path.exists());
1454 assert!(output.path.join("receipt.json").exists());
1455 assert!(output.path.join("merkle.json").exists());
1456 assert!(output.path.join("render.json").exists());
1457 assert!(output.path.join("preview.html").exists());
1458 assert!(output.receipt_digest.starts_with("sha256:"));
1459 assert!(output.file_count >= 4);
1460
1461 let read_back = read_package(&output.path).unwrap();
1463 assert_eq!(read_back.session.id, "ssn_pkg_test");
1464 assert_eq!(read_back.type_, RECEIPT_TYPE);
1465
1466 let _ = std::fs::remove_dir_all(&tmp);
1467 }
1468
1469 #[test]
1470 fn verify_valid_package() {
1471 let receipt = make_receipt();
1472 let tmp = std::env::temp_dir().join(format!("treeship-pkg-verify-{}", rand::random::<u32>()));
1473
1474 let output = build_package(&receipt, &tmp).unwrap();
1475 let checks = verify_package(&output.path).unwrap();
1476
1477 let fails: Vec<_> = checks.iter().filter(|c| c.status == VerifyStatus::Fail).collect();
1478 assert!(fails.is_empty(), "unexpected failures: {fails:?}");
1479
1480 let passes: Vec<_> = checks.iter().filter(|c| c.status == VerifyStatus::Pass).collect();
1481 assert!(passes.len() >= 5, "expected at least 5 pass checks, got {}", passes.len());
1482
1483 let _ = std::fs::remove_dir_all(&tmp);
1484 }
1485
1486 #[test]
1490 fn verify_warns_when_reconcile_degraded() {
1491 let mut receipt = make_receipt();
1492 receipt.proofs.reconcile_degraded = true;
1493 let tmp = std::env::temp_dir().join(format!("treeship-pkg-degraded-{}", rand::random::<u32>()));
1494
1495 let output = build_package(&receipt, &tmp).unwrap();
1496 let checks = verify_package(&output.path).unwrap();
1497
1498 let warned = checks.iter().any(|c| c.name == "reconcile_degraded" && c.status == VerifyStatus::Warn);
1499 assert!(warned, "expected a reconcile_degraded WARN, got {checks:?}");
1500 let fails: Vec<_> = checks.iter().filter(|c| c.status == VerifyStatus::Fail).collect();
1502 assert!(fails.is_empty(), "must not hard-fail: {fails:?}");
1503
1504 let _ = std::fs::remove_dir_all(&tmp);
1505 }
1506
1507 #[test]
1508 fn verify_no_degraded_warn_when_clean() {
1509 let receipt = make_receipt();
1511 let tmp = std::env::temp_dir().join(format!("treeship-pkg-clean-{}", rand::random::<u32>()));
1512 let output = build_package(&receipt, &tmp).unwrap();
1513 let checks = verify_package(&output.path).unwrap();
1514 assert!(
1515 !checks.iter().any(|c| c.name == "reconcile_degraded"),
1516 "clean receipt must not emit a reconcile_degraded check"
1517 );
1518 let _ = std::fs::remove_dir_all(&tmp);
1519 }
1520
1521 #[test]
1522 fn verify_detects_missing_receipt() {
1523 let tmp = std::env::temp_dir().join(format!("treeship-pkg-empty-{}", rand::random::<u32>()));
1524 std::fs::create_dir_all(&tmp).unwrap();
1525
1526 let err = read_package(&tmp);
1527 assert!(err.is_err());
1528
1529 let _ = std::fs::remove_dir_all(&tmp);
1530 }
1531
1532 #[test]
1533 fn preview_html_contains_session_info() {
1534 let receipt = make_receipt();
1535 let html = render_preview_html(&receipt);
1536 assert!(html.contains("ssn_pkg_test"));
1537 assert!(html.contains("treeship.dev"));
1538 assert!(html.contains("Timeline"));
1539
1540 assert!(
1550 html.contains("'__RECEIPT'+'_JSON__'"),
1551 "JS placeholder check was clobbered by the receipt substitution",
1552 );
1553 assert!(
1554 !html.contains("application/json\">__RECEIPT_JSON__</script>"),
1555 "data slot was not substituted with the receipt JSON",
1556 );
1557 assert_eq!(
1561 html.matches("__RECEIPT_JSON__").count(),
1562 0,
1563 "no raw placeholder token should remain after substitution",
1564 );
1565 }
1566}