Skip to main content

treeship_core/session/
package.rs

1//! `.treeship` package builder and reader.
2//!
3//! A `.treeship` package is a directory (or tar archive) containing:
4//!
5//! - `receipt.json`   -- the canonical Session Receipt
6//! - `merkle.json`    -- standalone Merkle tree data
7//! - `render.json`    -- Explorer render hints
8//! - `artifacts/`     -- referenced artifact payloads
9//! - `proofs/`        -- inclusion proofs and zk proofs
10//! - `preview.html`   -- static preview (optional)
11
12use 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/// Errors from package operations.
26#[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
51/// Manifest file inside the package root.
52const 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
59// Approval Authority package layout (v0.9.9 PR 4).
60// approvals/index.json -- top-level index of every approval evidence
61//                          file in this package
62// approvals/grants/<grant_id>.json    -- copy of the signed
63//                          ApprovalStatement envelope (already in
64//                          artifacts/ via the chain; mirrored here for
65//                          single-directory access during verify)
66// approvals/uses/<use_id>.json        -- ApprovalUse record from the
67//                          local journal at session-close time
68// approvals/checkpoints/<id>.json     -- JournalCheckpoint records that
69//                          cover the included uses (PR 6 Hub
70//                          checkpoint signing extends this)
71const 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/// Optional approval evidence to embed in the package alongside the
78/// receipt + artifacts. None means "no approvals consumed during this
79/// session, or none worth exporting." Empty vectors mean "we looked and
80/// found nothing"; the resulting package omits the `approvals/` dir
81/// entirely so absence is unambiguous.
82///
83/// Ownership of the evidence stays with the caller: `session::close`
84/// gathers the grant envelopes from the chain, the uses from the local
85/// journal, and any covering checkpoints, then hands them off here.
86#[derive(Debug, Clone, Default)]
87pub struct ApprovalsBundle {
88    /// Bytes of the signed ApprovalStatement envelopes that authorized
89    /// any consumed uses. Each entry is `(grant_id, raw_envelope_json)`.
90    /// Stored verbatim so the package's verifier can re-check the
91    /// signature without re-serializing.
92    pub grants:      Vec<(String, Vec<u8>)>,
93    /// ApprovalUse records pulled from the local journal at close time.
94    /// `action_artifact_id` should be backfilled before passing to
95    /// build_package (see `commands/session.rs`).
96    pub uses:        Vec<ApprovalUse>,
97    /// JournalCheckpoints that cover the included uses. Optional; may
98    /// be empty even when uses are present (PR 6 fills these in).
99    pub checkpoints: Vec<JournalCheckpoint>,
100    /// Explicit revocations we wanted to surface (e.g. a use whose
101    /// grant was revoked after consumption -- the package should still
102    /// show the consumed evidence and the revocation alongside).
103    /// Empty in PR 4; reserved.
104    pub revocations: Vec<ApprovalRevocation>,
105
106    /// Bytes of each action artifact's signed envelope that consumed an
107    /// approval. Each entry is `(action_artifact_id, raw_envelope_json)`.
108    /// v0.9.10 PR A: shipped to close the action↔use binding gap. The
109    /// verifier extracts `meta.approval_use_id` from each envelope and
110    /// cross-checks it against the package's use records. Empty in
111    /// pre-v0.9.10 packages; readers must treat absence as "binding
112    /// not asserted by package" rather than "binding present and OK."
113    pub action_envelopes: Vec<(String, Vec<u8>)>,
114}
115
116/// `approvals/index.json` -- top-level inventory of evidence in the
117/// package. Lets a consumer pre-flight what's there before opening
118/// every file; doubles as a stable shape for downstream tooling.
119#[derive(Debug, Clone, Serialize, Deserialize)]
120pub struct ApprovalsIndex {
121    /// Stable schema marker so future versions can fan out cleanly.
122    #[serde(rename = "type")]
123    pub type_: String,
124    pub schema_version: u32,
125    /// Stable kebab-case ids of grants present. Order matches
126    /// `grants/` filename order.
127    pub grants:      Vec<String>,
128    /// Use ids present.
129    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
138/// Result of building a package.
139pub struct PackageOutput {
140    /// Path to the package directory.
141    pub path: PathBuf,
142    /// SHA-256 digest of the canonical receipt.json.
143    pub receipt_digest: String,
144    /// Merkle root hex (if present).
145    pub merkle_root: Option<String>,
146    /// Number of files in the package.
147    pub file_count: usize,
148}
149
150/// Build a `.treeship` package directory from a composed receipt.
151///
152/// Writes all package files into `output_dir/<session_id>.treeship/`.
153/// Returns metadata about the written package.
154///
155/// Backwards-compatible wrapper: callers that don't have approval
156/// evidence to export pass through here unchanged. Callers that do
157/// (`session::close` with consumed approvals) call
158/// `build_package_with_approvals` directly.
159pub 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
166/// Like `build_package` but also embeds approval evidence (PR 4 of v0.9.9).
167/// `bundle = None` is identical to `build_package`; the `approvals/`
168/// directory is omitted entirely so absence stays unambiguous.
169pub 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    // 1. receipt.json -- canonical serialization
184    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    // 2. merkle.json -- standalone copy of the Merkle section
192    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    // 3. render.json
197    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    // 4. Write inclusion proofs as individual files
202    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    // 5. preview.html stub
210    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    // 6. Approval evidence (v0.9.9 PR 4). Only writes when the caller
217    // supplied a bundle AND that bundle has at least one entry; an empty
218    // bundle behaves the same as None so a session with no consumed
219    // approvals doesn't leave behind an empty `approvals/` directory.
220    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            // v0.9.10 PR A: write action envelopes that consumed an
226            // approval. The artifacts/ directory was created earlier
227            // for the package layout but never populated; closing the
228            // action↔use binding gap requires the verifier to be able
229            // to read each consuming action's `meta.approval_use_id`.
230            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
309/// Sanitize an id (artifact_id, use_id, checkpoint_id) into a filesystem-safe
310/// filename. Underscores everything that isn't alphanumeric, dash, or dot.
311/// Not a security boundary; the digest chain is the integrity check.
312fn 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
318/// Read approval evidence embedded in a package, if any. Returns
319/// `Ok(ApprovalsBundle::default())` when the package has no `approvals/`
320/// directory (the typical case for sessions that didn't consume any
321/// scoped approvals). Errors only on malformed JSON inside files that
322/// the index claims exist.
323///
324/// Quiet on missing-directory by design: PR 4 packages and pre-PR-4
325/// packages should both round-trip through verify without spurious
326/// failures.
327pub 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    // Grants are raw envelopes by file; we don't parse here, the
336    // verify layer can re-check the signature.
337    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    // v0.9.10 PR A: read action envelopes shipped to support the
374    // action↔use binding check. Pre-v0.9.10 packages have an empty
375    // artifacts/ dir (the dir was created but never populated); the
376    // bundle's `action_envelopes` stays empty in that case, and the
377    // verifier reports the binding row honestly as "not asserted by
378    // package" rather than silently passing.
379    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
394/// Read and parse a `.treeship` package from disk.
395pub 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
414/// Verify a `.treeship` package locally.
415///
416/// Returns a list of check results. All must pass for the package to be valid.
417///
418/// Auto-loads the operator's trust roots from
419/// `TrustRootStore::default_path()`. Use
420/// [`verify_package_with_trust`] when the trust store is already in
421/// hand (CLI paths that take a `Ctx`, or tests).
422///
423/// Audit lane J fix-up: `open_default_or_empty` propagates `Malformed`
424/// and `PermissionsTooOpen` errors -- those are operator
425/// misconfiguration that must NOT be silently downgraded to an empty
426/// trust store (an empty store fails verification of any hub-org
427/// checkpoint, which is the right end-state, but the operator needs a
428/// clear "your trust file is broken" diagnostic instead of a misleading
429/// "untrusted issuer" message). Surface the error as a `trust-root`
430/// fail row and stop before doing real work that depends on trust.
431pub 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            // Build a minimal check list so the caller's printer still
436            // renders a coherent failure rather than silently routing
437            // through a fake empty store.
438            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
447/// Like `verify_package` but takes an explicit `TrustRootStore` so the
448/// caller can verify with a constructed-in-memory trust set (tests) or
449/// a non-default location (CLI `--trust-roots`).
450pub 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    // 1. receipt.json exists and parses
457    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    // 2. Type field
469    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    // 3. Determinism: re-serialize and check digest matches.
476    //
477    // IMPORTANT SCOPE NOTE (do not read this row as integrity): this only
478    // confirms the receipt struct round-trips to the same bytes. It is NOT a
479    // signature check. The Merkle root below covers ONLY the artifact IDs;
480    // the receipt's timeline, side_effects, tool_usage, and narrative are
481    // composed from the (unsigned) event log and are NOT cryptographically
482    // bound by anything in this package. An attacker who edits those fields
483    // and re-serializes will pass determinism and pass the Merkle check.
484    // The authenticated anchor over the whole receipt is the actor-signed
485    // `session.v1` record (which binds receipt_digest) in the agent's chain;
486    // embedding + requiring it here is tracked as a follow-up. Until then,
487    // `package verify` authenticates the ARTIFACTS, not the narrative, and
488    // says so via the explicit scope check below.
489    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        // Not a hard failure -- pretty-print whitespace may differ
496        checks.push(VerifyCheck::warn("determinism", "receipt.json does not byte-match after re-serialization"));
497    }
498
499    // 3b. Honest scope of what this package authenticates. The receipt body
500    // (timeline / side_effects / tool_usage / narrative) is derived from the
501    // unsigned event log and carries no signature in the package, so a reader
502    // must not mistake a green package for an authenticated ledger of what
503    // the agent did. Only the artifacts + Merkle root are cryptographically
504    // bound.
505    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    // 4. Merkle root re-computation
511    if !receipt.artifacts.is_empty() {
512        // Recompute under the receipt's declared merkle version so
513        // legacy (v0.10.2 and earlier, version=1, no domain separation)
514        // receipts continue to verify. New receipts always emit v2.
515        // Construct through the validating `with_version` so an unknown
516        // version surfaces as a hard fail rather than silently falling
517        // back to v1.
518        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                // Skip the remaining merkle/inclusion work; emit the
527                // leaf_count + timeline tail and return.
528                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        // 5. Verify each inclusion proof. Per-proof merkle_version must
554        // match the receipt section's declared version — drift is a
555        // hard fail (smuggled v1 proof inside a v2 receipt would
556        // otherwise dispatch through the weaker hashing path).
557        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    // 6. Leaf count matches artifacts
591    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    // 7. Timeline ordering (determinism rule: timestamp, sequence_no, event_id)
601    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    // event_log completeness: when session::close skipped malformed
612    // event log lines, the count is recorded on receipt.proofs.event_log_skipped.
613    // Surface as WARN (not FAIL) because the receipt is still
614    // cryptographically valid -- we just want a downstream verifier to
615    // know that some evidence was dropped before the receipt was sealed.
616    // A future --strict flag can promote this to FAIL.
617    // Codex adversarial review finding #8.
618    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    // AUD-07: the git-diff backstop was disabled between session start and
643    // close (git worked at start — a HEAD was captured — but not at close).
644    // A file changed via a non-AgentWroteFile channel could be missing from
645    // the "Files changed" ledger with no other signal, so this must not read
646    // as a clean, complete audit trail.
647    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    // 8. Approval evidence -- v0.9.9 PR 4. Three independent replay
658    // checks, each emitted as its own VerifyCheck row so the printer
659    // (and downstream tooling) can render them separately.
660    //
661    //   replay-package-local      duplicate uses INSIDE this package
662    //   replay-included-checkpoint  embedded JournalCheckpoints verify standalone
663    //
664    // The local-journal level requires access to the workspace journal,
665    // which the package alone doesn't carry; that check runs in the CLI
666    // verify_package wrapper that has Ctx access. The hub-org level is
667    // reserved for PR 6 -- not claimed without a real Hub checkpoint.
668    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
674/// Tail of `verify_package`: emit leaf_count and timeline-order checks.
675/// Used by the early-return path when an unknown merkle version aborts
676/// Merkle recomputation — those two checks are independent of the tree
677/// version and still meaningful to surface.
678fn 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
707/// Emit the package-local + included-checkpoint replay checks. Both are
708/// fully offline: package-local scans the embedded uses for duplicates;
709/// included-checkpoint walks the embedded checkpoint records and
710/// re-derives each `record_digest` against its stored value.
711///
712/// The local-journal check is NOT here -- it requires workspace access
713/// and is added by the CLI wrapper in `commands/package.rs` that has the
714/// resolved config_path. Keeping these two pure means an offline tool
715/// (Hub-side validator, third-party verifier) can run the same checks
716/// without needing a Treeship workspace.
717pub(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        // Nothing to assert. Stay quiet rather than emit a "skipped"
724        // row -- session packages without approvals shouldn't drag in
725        // approval rows by accident.
726        return;
727    }
728
729    // -- replay-package-local --
730    // Two distinct violation cases inside the package:
731    //   (a) uses sharing (grant_id, nonce_digest) EXCEED max_uses on
732    //       that grant. Two uses of a max_uses=2 grant is fine; three
733    //       is the violation. max_uses is read from the use record's
734    //       own `max_uses` field (a snapshot from consume time).
735    //   (b) two ApprovalUse records with the same use_id -- a copy
736    //       artifact from a corrupt build, never legitimate.
737    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    // -- replay-included-checkpoint --
781    // For each checkpoint, recompute its record_digest from canonical
782    // form. If the stored digest doesn't match, the checkpoint was
783    // tampered after sealing.
784    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    // -- approval-use-record-digest --
809    // Each ApprovalUse carries its own record_digest computed over the
810    // canonical form of the record (minus the digest itself). Tampering
811    // any field changes the digest. v0.9.10 PR A renames this from the
812    // older `approval-use-integrity` because the prior label suggested
813    // it covered nonce/action binding -- it didn't, and Codex's v0.9.9
814    // adversarial review flagged the over-claim. The honest scope of
815    // this row is "each use's stored digest matches its canonical
816    // recompute"; the binding checks are now separate rows below.
817    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    // -- approval-use-nonce-binding --
842    // Cross-check each use's `nonce_digest` against the corresponding
843    // grant's *signed* nonce. v0.9.9 trusted the use's nonce_digest
844    // verbatim, which let an attacker who controls the package mutate
845    // it (and recompute record_digest) to claim consumption of a grant
846    // whose nonce was never actually used. This row closes that gap.
847    //
848    // Discipline: the grant envelope is the source of truth. Before
849    // pulling the raw `nonce` from the grant's payload we verify the
850    // envelope's *content addressing* -- recompute the artifact_id
851    // from the envelope's PAE bytes and confirm it equals the grant_id
852    // the package claims. v0.9.10 PR A round 1 only parsed the
853    // envelope without this check; that left a forgery window where
854    // an attacker could ship an arbitrary unsigned envelope under any
855    // grant_id filename. v0.9.10 PR A round 2 closes the window: only
856    // a bytes-identical envelope produces the same artifact_id under
857    // SHA-256.
858    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            // Content-addressing check: derive the artifact_id from
873            // the envelope's PAE bytes and confirm it matches the
874            // claimed grant_id. If they differ the envelope was
875            // substituted or its bytes were tampered post-sign.
876            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    // -- approval-use-action-binding --
936    // Cross-check each consuming action's `meta.approval_use_id`
937    // against the package's use records. v0.9.9 ignored this pointer
938    // entirely; the package didn't even ship action envelopes, so the
939    // verifier could not see the field. v0.9.10 PR A: action envelopes
940    // ride along in `artifacts/`, and this row pins that every action
941    // declaring it consumed an approval has a use record for that
942    // exact use_id, with matching grant_id and matching
943    // `nonce_digest(approval_nonce)`.
944    //
945    // Honesty rule: when bundle.action_envelopes is empty (pre-v0.9.10
946    // packages, or a v0.9.10 package with no consuming actions
947    // recorded), this row reports `not asserted by package` rather
948    // than silent PASS.
949    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                // Content-addressing gate: derive the artifact_id
971                // from the envelope's PAE bytes and require it to
972                // match the filename stem the package shipped this
973                // envelope under. Without this gate an attacker
974                // controlling the package can write any forged
975                // unsigned action JSON to artifacts/<id>.json and the
976                // binding rows would trust it.
977                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    // -- approval-use-chain-continuity --
1049    // v0.9.9 verified each use's individual record_digest but never
1050    // walked the `previous_record_digest` chain across the embedded
1051    // records. An attacker could rewrite an entire chain consistently
1052    // (recomputing each digest along the way) and the per-record
1053    // checks all passed.
1054    //
1055    // Algorithm (v0.9.10 PR A round 2): build a graph of embedded
1056    // records keyed by record_digest, then require the embedded
1057    // records to form a SINGLE linked list with exactly one genesis
1058    // (previous_record_digest == "") and no cycles, forks, or
1059    // disconnected subchains.
1060    //
1061    //   - Dangling prev pointer (not in `owned`) -> fail.
1062    //   - More than one record with prev == ""    -> fail (mid-chain
1063    //     genesis is a forgery primitive).
1064    //   - Two records sharing the same prev       -> fail (fork).
1065    //   - Cycle reached during the walk           -> fail.
1066    //   - Walk doesn't reach every record         -> fail (disconnected
1067    //     subchain).
1068    //
1069    // We can only check *internal* consistency offline -- the package
1070    // doesn't ship the workspace journal's full history, so the chain
1071    // we see may be a contiguous prefix or window. Anchoring against
1072    // a Hub-signed checkpoint is replay-hub-org's job; here we report
1073    // structural consistency only.
1074    if !bundle.uses.is_empty() || !bundle.checkpoints.is_empty() {
1075        use std::collections::{HashMap, HashSet};
1076        // Each record carries a label for diagnostics + its own
1077        // record_digest + previous_record_digest.
1078        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        // Dangling prev: pointer not in owned set.
1101        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        // Genesis count: only one record allowed to have prev == "".
1110        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        // Forks: two records sharing the same non-empty prev.
1119        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        // Walk from genesis (if exactly one) following digest-as-prev
1135        // links. Detect cycles and unreachable records.
1136        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            // Disconnected: walk didn't include every node.
1160            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; // reserved for future cross-checks
1174        }
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    // -- replay-hub-org -- v0.9.9 PR 6.
1193    // The strongest level Treeship can speak to today. The release
1194    // rule is non-negotiable: PASS only when (1) at least one embedded
1195    // checkpoint declares kind=HubOrg, (2) every required Hub field is
1196    // populated, (3) the signature verifies against the embedded
1197    // public key, AND (4) the checkpoint covers every embedded
1198    // ApprovalUse via covered_use_ids. Anything short of that means
1199    // "no row" or "fail" -- never silent pass.
1200    //
1201    // No row at all when the package has no Hub-kind checkpoint:
1202    // matches the v0.9.9 PR 4-5 behavior where the panel renders
1203    // "- hub-org   not checked (no Hub checkpoint in package)" so a
1204    // reader doesn't misread an absent row as a failure.
1205    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        // Security-critical failures (untrusted-issuer / tampered /
1215        // not-hub-kind) must FAIL unconditionally, not warn. Audit
1216        // lane J fix-up: previously these emitted WARN and the CLI
1217        // wrapper's --strict promoted to FAIL, which meant the
1218        // headline audit case (self-signed hub-org forgery) passed
1219        // green-but-yellow in default mode. The release rule is
1220        // "trust pinning is on by default"; expressed in this row
1221        // as "any signature/issuer failure is a hard fail."
1222        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                    // Coverage: every embedded use_id MUST appear in
1229                    // this checkpoint's covered_use_ids. A checkpoint
1230                    // that doesn't cover the package's uses cannot
1231                    // promote replay-hub-org for those uses.
1232                    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                    // Filter ensures this is unreachable; keep the
1274                    // arm so a future filter relaxation doesn't go
1275                    // silent.
1276                    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            // Untrusted issuer or tampered signature: fail-by-default
1300            // regardless of --strict. Self-signed forgeries must not
1301            // pass yellow.
1302            checks.push(VerifyCheck::fail(
1303                "replay-hub-org",
1304                &details.join("; "),
1305            ));
1306        } else {
1307            // Hub checkpoint is present but does not satisfy every
1308            // non-security gate (missing-field, coverage gap).
1309            // Default mode warns; the CLI verify wrapper's --strict
1310            // promotes to fail.
1311            checks.push(VerifyCheck::warn(
1312                "replay-hub-org",
1313                &details.join("; "),
1314            ));
1315        }
1316    }
1317    // No hub-org checkpoints embedded -> no row. The Approval
1318    // Authority panel still renders "- hub-org   not checked".
1319
1320    let _ = ReplayCheckLevel::HubOrg;
1321    let _ = approval_revocation_record_digest as fn(&ApprovalRevocation) -> String;
1322    let _ = ReplayCheck::not_performed;
1323}
1324
1325/// A single verification check result.
1326#[derive(Debug, Clone)]
1327pub struct VerifyCheck {
1328    pub name: String,
1329    pub status: VerifyStatus,
1330    pub detail: String,
1331}
1332
1333/// Status of a verification check.
1334#[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
1359/// HTML template for the self-contained verifier preview.
1360/// Loaded at compile time so the binary carries no runtime file dependencies.
1361const PREVIEW_TEMPLATE: &str = include_str!("preview_template.html");
1362
1363/// Generate a self-contained preview.html that embeds the receipt JSON
1364/// and runs Merkle verification client-side using Web Crypto API.
1365///
1366/// The HTML works fully air-gapped: no network calls, no CDN, no server.
1367/// Open it in any modern browser and it automatically verifies the receipt
1368/// and shows pass/fail for each check.
1369pub fn render_preview_html(receipt: &SessionReceipt) -> String {
1370    let receipt_json = serde_json::to_string_pretty(receipt)
1371        .unwrap_or_else(|_| "{}".to_string());
1372    // Defense-in-depth: escape </script sequences so a malicious receipt
1373    // field cannot break out of the JSON data block. The primary defense
1374    // is type="application/json" which the HTML parser does not execute,
1375    // but this escaping adds a second layer.
1376    // Escape ALL '<' as '\u003c' in the JSON string to prevent any
1377    // case-variant of </script> from breaking out of the data block.
1378    // This is bulletproof: no HTML parser can see a tag open inside the JSON.
1379    let safe_json = receipt_json.replace('<', r"\u003c");
1380
1381    // The only placeholder that must take the receipt JSON is the data
1382    // block. replacen(.., 1) substitutes exactly that first occurrence, so
1383    // even if the token is ever reused elsewhere in the template (e.g. a JS
1384    // placeholder check) the receipt body is never injected into it. The
1385    // template's own placeholder check uses a split sentinel for the same
1386    // reason. The page title is set at runtime from the parsed JSON.
1387    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        // Read back
1462        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    // AUD-07: a receipt stamped reconcile_degraded must surface a WARN on
1487    // verify, so a consumer is told the file ledger may be incomplete rather
1488    // than reading the package as a clean, complete audit trail.
1489    #[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        // It is a WARN, not a hard fail (the signatures/Merkle are still valid).
1501        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        // The default receipt has reconcile_degraded=false: no such WARN.
1510        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        // Regression: the receipt JSON must land ONLY in the data block,
1541        // never in the inline JS. A prior bug used replace() (all matches)
1542        // against a template that carried the placeholder token twice (data
1543        // slot + a JS placeholder check), injecting the receipt body into a
1544        // JS string literal. That produced an uncaught SyntaxError, so the
1545        // whole script never ran and the preview hung on "Verifying
1546        // receipt...". The JS check now uses a split sentinel that must
1547        // survive substitution verbatim, and replacen(.., 1) fills only the
1548        // first occurrence.
1549        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        // The session id (a receipt value) must appear inside the data block,
1558        // not leak into executable JS, so a quick structural sanity check:
1559        // there is exactly one unsubstituted token left at most (none here).
1560        assert_eq!(
1561            html.matches("__RECEIPT_JSON__").count(),
1562            0,
1563            "no raw placeholder token should remain after substitution",
1564        );
1565    }
1566}