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    approval_revocation_record_digest, approval_use_record_digest,
20    journal_checkpoint_record_digest, ApprovalRevocation, ApprovalUse, JournalCheckpoint,
21    ReplayCheck, ReplayCheckLevel,
22};
23
24/// Errors from package operations.
25#[derive(Debug)]
26pub enum PackageError {
27    Io(std::io::Error),
28    Json(serde_json::Error),
29    InvalidPackage(String),
30}
31
32impl std::fmt::Display for PackageError {
33    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
34        match self {
35            Self::Io(e) => write!(f, "package io: {e}"),
36            Self::Json(e) => write!(f, "package json: {e}"),
37            Self::InvalidPackage(msg) => write!(f, "invalid package: {msg}"),
38        }
39    }
40}
41
42impl std::error::Error for PackageError {}
43impl From<std::io::Error> for PackageError {
44    fn from(e: std::io::Error) -> Self {
45        Self::Io(e)
46    }
47}
48impl From<serde_json::Error> for PackageError {
49    fn from(e: serde_json::Error) -> Self {
50        Self::Json(e)
51    }
52}
53
54/// Manifest file inside the package root.
55const RECEIPT_FILE: &str = "receipt.json";
56const MERKLE_FILE: &str = "merkle.json";
57const RENDER_FILE: &str = "render.json";
58const ARTIFACTS_DIR: &str = "artifacts";
59const PROOFS_DIR: &str = "proofs";
60const PREVIEW_FILE: &str = "preview.html";
61
62// Approval Authority package layout (v0.9.9 PR 4).
63// approvals/index.json -- top-level index of every approval evidence
64//                          file in this package
65// approvals/grants/<grant_id>.json    -- copy of the signed
66//                          ApprovalStatement envelope (already in
67//                          artifacts/ via the chain; mirrored here for
68//                          single-directory access during verify)
69// approvals/uses/<use_id>.json        -- ApprovalUse record from the
70//                          local journal at session-close time
71// approvals/checkpoints/<id>.json     -- JournalCheckpoint records that
72//                          cover the included uses (PR 6 Hub
73//                          checkpoint signing extends this)
74const APPROVALS_DIR: &str = "approvals";
75const APPROVALS_GRANTS: &str = "approvals/grants";
76const APPROVALS_USES: &str = "approvals/uses";
77const APPROVALS_CHECKPOINTS: &str = "approvals/checkpoints";
78const APPROVALS_INDEX_FILE: &str = "approvals/index.json";
79
80/// Optional approval evidence to embed in the package alongside the
81/// receipt + artifacts. None means "no approvals consumed during this
82/// session, or none worth exporting." Empty vectors mean "we looked and
83/// found nothing"; the resulting package omits the `approvals/` dir
84/// entirely so absence is unambiguous.
85///
86/// Ownership of the evidence stays with the caller: `session::close`
87/// gathers the grant envelopes from the chain, the uses from the local
88/// journal, and any covering checkpoints, then hands them off here.
89#[derive(Debug, Clone, Default)]
90pub struct ApprovalsBundle {
91    /// Bytes of the signed ApprovalStatement envelopes that authorized
92    /// any consumed uses. Each entry is `(grant_id, raw_envelope_json)`.
93    /// Stored verbatim so the package's verifier can re-check the
94    /// signature without re-serializing.
95    pub grants: Vec<(String, Vec<u8>)>,
96    /// ApprovalUse records pulled from the local journal at close time.
97    /// `action_artifact_id` should be backfilled before passing to
98    /// build_package (see `commands/session.rs`).
99    pub uses: Vec<ApprovalUse>,
100    /// JournalCheckpoints that cover the included uses. Optional; may
101    /// be empty even when uses are present (PR 6 fills these in).
102    pub checkpoints: Vec<JournalCheckpoint>,
103    /// Explicit revocations we wanted to surface (e.g. a use whose
104    /// grant was revoked after consumption -- the package should still
105    /// show the consumed evidence and the revocation alongside).
106    /// Empty in PR 4; reserved.
107    pub revocations: Vec<ApprovalRevocation>,
108
109    /// Bytes of each action artifact's signed envelope that consumed an
110    /// approval. Each entry is `(action_artifact_id, raw_envelope_json)`.
111    /// v0.9.10 PR A: shipped to close the action↔use binding gap. The
112    /// verifier extracts `meta.approval_use_id` from each envelope and
113    /// cross-checks it against the package's use records. Empty in
114    /// pre-v0.9.10 packages; readers must treat absence as "binding
115    /// not asserted by package" rather than "binding present and OK."
116    pub action_envelopes: Vec<(String, Vec<u8>)>,
117}
118
119/// `approvals/index.json` -- top-level inventory of evidence in the
120/// package. Lets a consumer pre-flight what's there before opening
121/// every file; doubles as a stable shape for downstream tooling.
122#[derive(Debug, Clone, Serialize, Deserialize)]
123pub struct ApprovalsIndex {
124    /// Stable schema marker so future versions can fan out cleanly.
125    #[serde(rename = "type")]
126    pub type_: String,
127    pub schema_version: u32,
128    /// Stable kebab-case ids of grants present. Order matches
129    /// `grants/` filename order.
130    pub grants: Vec<String>,
131    /// Use ids present.
132    pub uses: Vec<String>,
133    pub checkpoints: Vec<String>,
134    pub revocations: Vec<String>,
135}
136
137impl ApprovalsIndex {
138    pub fn type_string() -> &'static str {
139        "treeship/approvals-index/v1"
140    }
141}
142
143/// Result of building a package.
144pub struct PackageOutput {
145    /// Path to the package directory.
146    pub path: PathBuf,
147    /// SHA-256 digest of the canonical receipt.json.
148    pub receipt_digest: String,
149    /// Merkle root hex (if present).
150    pub merkle_root: Option<String>,
151    /// Number of files in the package.
152    pub file_count: usize,
153}
154
155/// Build a `.treeship` package directory from a composed receipt.
156///
157/// Writes all package files into `output_dir/<session_id>.treeship/`.
158/// Returns metadata about the written package.
159///
160/// Backwards-compatible wrapper: callers that don't have approval
161/// evidence to export pass through here unchanged. Callers that do
162/// (`session::close` with consumed approvals) call
163/// `build_package_with_approvals` directly.
164pub fn build_package(
165    receipt: &SessionReceipt,
166    output_dir: &Path,
167) -> Result<PackageOutput, PackageError> {
168    build_package_with_approvals(receipt, output_dir, None)
169}
170
171/// Like `build_package` but also embeds approval evidence (PR 4 of v0.9.9).
172/// `bundle = None` is identical to `build_package`; the `approvals/`
173/// directory is omitted entirely so absence stays unambiguous.
174pub fn build_package_with_approvals(
175    receipt: &SessionReceipt,
176    output_dir: &Path,
177    bundle: Option<&ApprovalsBundle>,
178) -> Result<PackageOutput, PackageError> {
179    let session_id = &receipt.session.id;
180    let pkg_dir = output_dir.join(format!("{session_id}.treeship"));
181
182    std::fs::create_dir_all(&pkg_dir)?;
183    std::fs::create_dir_all(pkg_dir.join(ARTIFACTS_DIR))?;
184    std::fs::create_dir_all(pkg_dir.join(PROOFS_DIR))?;
185
186    let mut file_count = 0usize;
187
188    // 1. receipt.json -- canonical serialization
189    let receipt_bytes = serde_json::to_vec_pretty(receipt)?;
190    std::fs::write(pkg_dir.join(RECEIPT_FILE), &receipt_bytes)?;
191    file_count += 1;
192
193    let receipt_hash = Sha256::digest(&receipt_bytes);
194    let receipt_digest = format!("sha256:{}", hex::encode(receipt_hash));
195
196    // 2. merkle.json -- standalone copy of the Merkle section
197    let merkle_bytes = serde_json::to_vec_pretty(&receipt.merkle)?;
198    std::fs::write(pkg_dir.join(MERKLE_FILE), &merkle_bytes)?;
199    file_count += 1;
200
201    // 3. render.json
202    let render_bytes = serde_json::to_vec_pretty(&receipt.render)?;
203    std::fs::write(pkg_dir.join(RENDER_FILE), &render_bytes)?;
204    file_count += 1;
205
206    // 4. Write inclusion proofs as individual files
207    for proof_entry in &receipt.merkle.inclusion_proofs {
208        let proof_bytes = serde_json::to_vec_pretty(proof_entry)?;
209        let filename = format!("{}.proof.json", proof_entry.artifact_id);
210        std::fs::write(pkg_dir.join(PROOFS_DIR).join(filename), &proof_bytes)?;
211        file_count += 1;
212    }
213
214    // 5. preview.html stub
215    if receipt.render.generate_preview {
216        let preview = render_preview_html(receipt);
217        std::fs::write(pkg_dir.join(PREVIEW_FILE), preview.as_bytes())?;
218        file_count += 1;
219    }
220
221    // 6. Approval evidence (v0.9.9 PR 4). Only writes when the caller
222    // supplied a bundle AND that bundle has at least one entry; an empty
223    // bundle behaves the same as None so a session with no consumed
224    // approvals doesn't leave behind an empty `approvals/` directory.
225    if let Some(b) = bundle {
226        if !b.grants.is_empty()
227            || !b.uses.is_empty()
228            || !b.checkpoints.is_empty()
229            || !b.revocations.is_empty()
230            || !b.action_envelopes.is_empty()
231        {
232            std::fs::create_dir_all(pkg_dir.join(APPROVALS_GRANTS))?;
233            std::fs::create_dir_all(pkg_dir.join(APPROVALS_USES))?;
234            std::fs::create_dir_all(pkg_dir.join(APPROVALS_CHECKPOINTS))?;
235            // v0.9.10 PR A: write action envelopes that consumed an
236            // approval. The artifacts/ directory was created earlier
237            // for the package layout but never populated; closing the
238            // action↔use binding gap requires the verifier to be able
239            // to read each consuming action's `meta.approval_use_id`.
240            std::fs::create_dir_all(pkg_dir.join(ARTIFACTS_DIR))?;
241            for (artifact_id, envelope_bytes) in &b.action_envelopes {
242                let safe = sanitize_filename(artifact_id);
243                std::fs::write(
244                    pkg_dir.join(ARTIFACTS_DIR).join(format!("{safe}.json")),
245                    envelope_bytes,
246                )?;
247                file_count += 1;
248            }
249
250            let mut grant_ids = Vec::with_capacity(b.grants.len());
251            for (grant_id, envelope_bytes) in &b.grants {
252                let safe = sanitize_filename(grant_id);
253                std::fs::write(
254                    pkg_dir.join(APPROVALS_GRANTS).join(format!("{safe}.json")),
255                    envelope_bytes,
256                )?;
257                grant_ids.push(grant_id.clone());
258                file_count += 1;
259            }
260
261            let mut use_ids = Vec::with_capacity(b.uses.len());
262            for u in &b.uses {
263                let safe = sanitize_filename(&u.use_id);
264                let bytes = serde_json::to_vec_pretty(u)?;
265                std::fs::write(
266                    pkg_dir.join(APPROVALS_USES).join(format!("{safe}.json")),
267                    &bytes,
268                )?;
269                use_ids.push(u.use_id.clone());
270                file_count += 1;
271            }
272
273            let mut checkpoint_ids = Vec::with_capacity(b.checkpoints.len());
274            for cp in &b.checkpoints {
275                let safe = sanitize_filename(&cp.checkpoint_id);
276                let bytes = serde_json::to_vec_pretty(cp)?;
277                std::fs::write(
278                    pkg_dir
279                        .join(APPROVALS_CHECKPOINTS)
280                        .join(format!("{safe}.json")),
281                    &bytes,
282                )?;
283                checkpoint_ids.push(cp.checkpoint_id.clone());
284                file_count += 1;
285            }
286
287            let mut revocation_ids = Vec::with_capacity(b.revocations.len());
288            for rev in &b.revocations {
289                let safe = sanitize_filename(&rev.revocation_id);
290                let bytes = serde_json::to_vec_pretty(rev)?;
291                std::fs::write(
292                    pkg_dir
293                        .join(APPROVALS_DIR)
294                        .join(format!("revocations-{safe}.json")),
295                    &bytes,
296                )?;
297                revocation_ids.push(rev.revocation_id.clone());
298                file_count += 1;
299            }
300
301            let index = ApprovalsIndex {
302                type_: ApprovalsIndex::type_string().into(),
303                schema_version: 1,
304                grants: grant_ids,
305                uses: use_ids,
306                checkpoints: checkpoint_ids,
307                revocations: revocation_ids,
308            };
309            let index_bytes = serde_json::to_vec_pretty(&index)?;
310            std::fs::write(pkg_dir.join(APPROVALS_INDEX_FILE), &index_bytes)?;
311            file_count += 1;
312        }
313    }
314
315    Ok(PackageOutput {
316        path: pkg_dir,
317        receipt_digest,
318        merkle_root: receipt.merkle.root.clone(),
319        file_count,
320    })
321}
322
323/// Sanitize an id (artifact_id, use_id, checkpoint_id) into a filesystem-safe
324/// filename. Underscores everything that isn't alphanumeric, dash, or dot.
325/// Not a security boundary; the digest chain is the integrity check.
326fn sanitize_filename(s: &str) -> String {
327    s.chars()
328        .map(|c| {
329            if c.is_ascii_alphanumeric() || c == '-' || c == '.' || c == '_' {
330                c
331            } else {
332                '_'
333            }
334        })
335        .collect()
336}
337
338/// Read approval evidence embedded in a package, if any. Returns
339/// `Ok(ApprovalsBundle::default())` when the package has no `approvals/`
340/// directory (the typical case for sessions that didn't consume any
341/// scoped approvals). Errors only on malformed JSON inside files that
342/// the index claims exist.
343///
344/// Quiet on missing-directory by design: PR 4 packages and pre-PR-4
345/// packages should both round-trip through verify without spurious
346/// failures.
347pub fn read_approvals_bundle(pkg_dir: &Path) -> Result<ApprovalsBundle, PackageError> {
348    let approvals_dir = pkg_dir.join(APPROVALS_DIR);
349    if !approvals_dir.is_dir() {
350        return Ok(ApprovalsBundle::default());
351    }
352
353    let mut bundle = ApprovalsBundle::default();
354
355    // Grants are raw envelopes by file; we don't parse here, the
356    // verify layer can re-check the signature.
357    let grants_dir = pkg_dir.join(APPROVALS_GRANTS);
358    if grants_dir.is_dir() {
359        for entry in std::fs::read_dir(&grants_dir)? {
360            let entry = entry?;
361            let path = entry.path();
362            if path.extension().and_then(|s| s.to_str()) != Some("json") {
363                continue;
364            }
365            let id = path
366                .file_stem()
367                .and_then(|s| s.to_str())
368                .unwrap_or("")
369                .to_string();
370            let bytes = std::fs::read(&path)?;
371            bundle.grants.push((id, bytes));
372        }
373    }
374
375    let uses_dir = pkg_dir.join(APPROVALS_USES);
376    if uses_dir.is_dir() {
377        for entry in std::fs::read_dir(&uses_dir)? {
378            let entry = entry?;
379            let path = entry.path();
380            if path.extension().and_then(|s| s.to_str()) != Some("json") {
381                continue;
382            }
383            let bytes = std::fs::read(&path)?;
384            let u: ApprovalUse = serde_json::from_slice(&bytes)?;
385            bundle.uses.push(u);
386        }
387    }
388
389    let cps_dir = pkg_dir.join(APPROVALS_CHECKPOINTS);
390    if cps_dir.is_dir() {
391        for entry in std::fs::read_dir(&cps_dir)? {
392            let entry = entry?;
393            let path = entry.path();
394            if path.extension().and_then(|s| s.to_str()) != Some("json") {
395                continue;
396            }
397            let bytes = std::fs::read(&path)?;
398            let cp: JournalCheckpoint = serde_json::from_slice(&bytes)?;
399            bundle.checkpoints.push(cp);
400        }
401    }
402
403    // v0.9.10 PR A: read action envelopes shipped to support the
404    // action↔use binding check. Pre-v0.9.10 packages have an empty
405    // artifacts/ dir (the dir was created but never populated); the
406    // bundle's `action_envelopes` stays empty in that case, and the
407    // verifier reports the binding row honestly as "not asserted by
408    // package" rather than silently passing.
409    let arts_dir = pkg_dir.join(ARTIFACTS_DIR);
410    if arts_dir.is_dir() {
411        for entry in std::fs::read_dir(&arts_dir)? {
412            let entry = entry?;
413            let path = entry.path();
414            if path.extension().and_then(|s| s.to_str()) != Some("json") {
415                continue;
416            }
417            let id = path
418                .file_stem()
419                .and_then(|s| s.to_str())
420                .unwrap_or("")
421                .to_string();
422            let bytes = std::fs::read(&path)?;
423            bundle.action_envelopes.push((id, bytes));
424        }
425    }
426
427    Ok(bundle)
428}
429
430/// Read and parse a `.treeship` package from disk.
431pub fn read_package(pkg_dir: &Path) -> Result<SessionReceipt, PackageError> {
432    let receipt_path = pkg_dir.join(RECEIPT_FILE);
433    if !receipt_path.exists() {
434        return Err(PackageError::InvalidPackage(format!(
435            "missing {RECEIPT_FILE} in {}",
436            pkg_dir.display()
437        )));
438    }
439    let bytes = std::fs::read(&receipt_path)?;
440    let receipt: SessionReceipt = serde_json::from_slice(&bytes)?;
441
442    if receipt.type_ != RECEIPT_TYPE {
443        return Err(PackageError::InvalidPackage(format!(
444            "unexpected type: {} (expected {RECEIPT_TYPE})",
445            receipt.type_
446        )));
447    }
448
449    Ok(receipt)
450}
451
452/// Verify a `.treeship` package locally.
453///
454/// Returns a list of check results. All must pass for the package to be valid.
455///
456/// Auto-loads the operator's trust roots from
457/// `TrustRootStore::default_path()`. Use
458/// [`verify_package_with_trust`] when the trust store is already in
459/// hand (CLI paths that take a `Ctx`, or tests).
460///
461/// Audit lane J fix-up: `open_default_or_empty` propagates `Malformed`
462/// and `PermissionsTooOpen` errors -- those are operator
463/// misconfiguration that must NOT be silently downgraded to an empty
464/// trust store (an empty store fails verification of any hub-org
465/// checkpoint, which is the right end-state, but the operator needs a
466/// clear "your trust file is broken" diagnostic instead of a misleading
467/// "untrusted issuer" message). Surface the error as a `trust-root`
468/// fail row and stop before doing real work that depends on trust.
469pub fn verify_package(pkg_dir: &Path) -> Result<Vec<VerifyCheck>, PackageError> {
470    let trust = match crate::trust::TrustRootStore::open_default_or_empty() {
471        Ok(t) => t,
472        Err(e) => {
473            // Build a minimal check list so the caller's printer still
474            // renders a coherent failure rather than silently routing
475            // through a fake empty store.
476            return Ok(vec![VerifyCheck::fail(
477                "trust-root",
478                &format!("trust store unreadable: {e}"),
479            )]);
480        }
481    };
482    verify_package_with_trust(pkg_dir, &trust)
483}
484
485/// Like `verify_package` but takes an explicit `TrustRootStore` so the
486/// caller can verify with a constructed-in-memory trust set (tests) or
487/// a non-default location (CLI `--trust-roots`).
488pub fn verify_package_with_trust(
489    pkg_dir: &Path,
490    trust: &crate::trust::TrustRootStore,
491) -> Result<Vec<VerifyCheck>, PackageError> {
492    let mut checks = Vec::new();
493
494    // 1. receipt.json exists and parses
495    let receipt = match read_package(pkg_dir) {
496        Ok(r) => {
497            checks.push(VerifyCheck::pass(
498                "receipt.json",
499                "Parses as valid Session Receipt",
500            ));
501            r
502        }
503        Err(e) => {
504            checks.push(VerifyCheck::fail(
505                "receipt.json",
506                &format!("Failed to parse: {e}"),
507            ));
508            return Ok(checks);
509        }
510    };
511
512    // 2. Type field
513    if receipt.type_ == RECEIPT_TYPE {
514        checks.push(VerifyCheck::pass("type", "Correct receipt type"));
515    } else {
516        checks.push(VerifyCheck::fail(
517            "type",
518            &format!("Expected {RECEIPT_TYPE}, got {}", receipt.type_),
519        ));
520    }
521
522    // 3. Determinism: re-serialize and check digest matches.
523    //
524    // IMPORTANT SCOPE NOTE (do not read this row as integrity): this only
525    // confirms the receipt struct round-trips to the same bytes. It is NOT a
526    // signature check. The Merkle root below covers ONLY the artifact IDs;
527    // the receipt's timeline, side_effects, tool_usage, and narrative are
528    // composed from the (unsigned) event log and are NOT cryptographically
529    // bound by anything in this package. An attacker who edits those fields
530    // and re-serializes will pass determinism and pass the Merkle check.
531    // The authenticated anchor over the whole receipt is the actor-signed
532    // `session.v1` record (which binds receipt_digest) in the agent's chain;
533    // embedding + requiring it here is tracked as a follow-up. Until then,
534    // `package verify` authenticates the ARTIFACTS, not the narrative, and
535    // says so via the explicit scope check below.
536    let receipt_path = pkg_dir.join(RECEIPT_FILE);
537    let on_disk = std::fs::read(&receipt_path)?;
538    let re_serialized = serde_json::to_vec_pretty(&receipt)?;
539    if on_disk == re_serialized {
540        checks.push(VerifyCheck::pass(
541            "determinism",
542            "receipt.json round-trips identically (structural, NOT a signature)",
543        ));
544    } else {
545        // Not a hard failure -- pretty-print whitespace may differ
546        checks.push(VerifyCheck::warn(
547            "determinism",
548            "receipt.json does not byte-match after re-serialization",
549        ));
550    }
551
552    // 3b. Honest scope of what this package authenticates. The receipt body
553    // (timeline / side_effects / tool_usage / narrative) is derived from the
554    // unsigned event log and carries no signature in the package, so a reader
555    // must not mistake a green package for an authenticated ledger of what
556    // the agent did. Only the artifacts + Merkle root are cryptographically
557    // bound.
558    checks.push(VerifyCheck::warn(
559        "receipt_body_binding",
560        "timeline/side-effects/narrative are NOT signed in this package — only the artifacts and Merkle root are cryptographically bound. For an authenticated record of the session, verify the actor-signed session.v1 record (or the published report).",
561    ));
562
563    // 4. Merkle root re-computation
564    if !receipt.artifacts.is_empty() {
565        // Recompute under the receipt's declared merkle version so
566        // legacy (v0.10.2 and earlier, version=1, no domain separation)
567        // receipts continue to verify. New receipts always emit v2.
568        // Construct through the validating `with_version` so an unknown
569        // version surfaces as a hard fail rather than silently falling
570        // back to v1.
571        let version = receipt.merkle.merkle_version;
572        let mut tree = match crate::merkle::MerkleTree::with_version(version) {
573            Ok(t) => t,
574            Err(e) => {
575                checks.push(VerifyCheck::fail(
576                    "merkle_root",
577                    &format!("receipt declared unknown merkle_version: {e}"),
578                ));
579                // Skip the remaining merkle/inclusion work; emit the
580                // leaf_count + timeline tail and return.
581                return Ok(finish_package_checks(checks, &receipt));
582            }
583        };
584        for art in &receipt.artifacts {
585            tree.append(&art.artifact_id);
586        }
587        let root_bytes = tree.root();
588        let recomputed_root = root_bytes.map(|r| format!("mroot_{}", hex::encode(r)));
589        let root_hex = root_bytes.map(|r| hex::encode(r)).unwrap_or_default();
590
591        if recomputed_root == receipt.merkle.root {
592            checks.push(VerifyCheck::pass(
593                "merkle_root",
594                "Merkle root matches recomputed value",
595            ));
596        } else {
597            checks.push(VerifyCheck::fail(
598                "merkle_root",
599                &format!(
600                    "Mismatch: on-disk {:?} vs recomputed {:?}",
601                    receipt.merkle.root, recomputed_root
602                ),
603            ));
604        }
605
606        // 5. Verify each inclusion proof. Per-proof merkle_version must
607        // match the receipt section's declared version — drift is a
608        // hard fail (smuggled v1 proof inside a v2 receipt would
609        // otherwise dispatch through the weaker hashing path).
610        for proof_entry in &receipt.merkle.inclusion_proofs {
611            if proof_entry.proof.merkle_version != version {
612                checks.push(VerifyCheck::fail(
613                    &format!("inclusion:{}", proof_entry.artifact_id),
614                    &format!(
615                        "proof merkle_version {} != receipt section v{}",
616                        proof_entry.proof.merkle_version, version,
617                    ),
618                ));
619                continue;
620            }
621            let verified = crate::merkle::MerkleTree::verify_proof(
622                version,
623                &root_hex,
624                &proof_entry.artifact_id,
625                &proof_entry.proof,
626            );
627            if verified {
628                checks.push(VerifyCheck::pass(
629                    &format!("inclusion:{}", proof_entry.artifact_id),
630                    "Inclusion proof valid",
631                ));
632            } else {
633                checks.push(VerifyCheck::fail(
634                    &format!("inclusion:{}", proof_entry.artifact_id),
635                    "Inclusion proof failed verification",
636                ));
637            }
638        }
639    } else {
640        checks.push(VerifyCheck::warn("merkle_root", "No artifacts to verify"));
641    }
642
643    // 6. Leaf count matches artifacts
644    if receipt.merkle.leaf_count == receipt.artifacts.len() {
645        checks.push(VerifyCheck::pass(
646            "leaf_count",
647            "Leaf count matches artifact count",
648        ));
649    } else {
650        checks.push(VerifyCheck::fail(
651            "leaf_count",
652            &format!(
653                "leaf_count {} != artifact count {}",
654                receipt.merkle.leaf_count,
655                receipt.artifacts.len()
656            ),
657        ));
658    }
659
660    // 7. Timeline ordering (determinism rule: timestamp, sequence_no, event_id)
661    let ordered = receipt.timeline.windows(2).all(|w| {
662        (&w[0].timestamp, w[0].sequence_no, &w[0].event_id)
663            <= (&w[1].timestamp, w[1].sequence_no, &w[1].event_id)
664    });
665    if ordered {
666        checks.push(VerifyCheck::pass(
667            "timeline_order",
668            "Timeline is correctly ordered",
669        ));
670    } else {
671        checks.push(VerifyCheck::fail(
672            "timeline_order",
673            "Timeline entries are not in deterministic order",
674        ));
675    }
676
677    // event_log completeness: when session::close skipped malformed
678    // event log lines, the count is recorded on receipt.proofs.event_log_skipped.
679    // Surface as WARN (not FAIL) because the receipt is still
680    // cryptographically valid -- we just want a downstream verifier to
681    // know that some evidence was dropped before the receipt was sealed.
682    // A future --strict flag can promote this to FAIL.
683    // Codex adversarial review finding #8.
684    if receipt.proofs.event_log_skipped > 0 {
685        checks.push(VerifyCheck::warn(
686            "event_log_completeness",
687            &format!(
688                "{} event(s) skipped during close (malformed lines in events.jsonl). \
689                 Receipt is cryptographically valid but does not represent the full event stream. \
690                 Inspect close-time stderr or the events.jsonl directly to investigate.",
691                receipt.proofs.event_log_skipped,
692            ),
693        ));
694    }
695
696    if receipt.proofs.reconcile_untracked_truncated > 0 {
697        checks.push(VerifyCheck::warn(
698            "reconcile_completeness",
699            &format!(
700                "untracked git reconcile exceeded cap {} (saw at least {}). \
701                 Per-file synthetic events were skipped and the receipt is bounded, not complete for untracked files.",
702                receipt.proofs.reconcile_untracked_cap,
703                receipt.proofs.reconcile_untracked_truncated,
704            ),
705        ));
706    }
707
708    // AUD-07: the git-diff backstop was disabled between session start and
709    // close (git worked at start — a HEAD was captured — but not at close).
710    // A file changed via a non-AgentWroteFile channel could be missing from
711    // the "Files changed" ledger with no other signal, so this must not read
712    // as a clean, complete audit trail.
713    if receipt.proofs.reconcile_degraded {
714        checks.push(VerifyCheck::warn(
715            "reconcile_degraded",
716            "the git reconcile backstop was UNAVAILABLE at session close although git worked at start \
717             (.git removed, corrupt index, or git not on PATH). Files changed outside a captured \
718             AgentWroteFile event may be MISSING from this receipt's file ledger — treat the \
719             \"Files changed\" list as incomplete.",
720        ));
721    }
722
723    // 8. Approval evidence -- v0.9.9 PR 4. Three independent replay
724    // checks, each emitted as its own VerifyCheck row so the printer
725    // (and downstream tooling) can render them separately.
726    //
727    //   replay-package-local      duplicate uses INSIDE this package
728    //   replay-included-checkpoint  embedded JournalCheckpoints verify standalone
729    //
730    // The local-journal level requires access to the workspace journal,
731    // which the package alone doesn't carry; that check runs in the CLI
732    // verify_package wrapper that has Ctx access. The hub-org level is
733    // reserved for PR 6 -- not claimed without a real Hub checkpoint.
734    let bundle = read_approvals_bundle(pkg_dir).unwrap_or_default();
735    add_approval_evidence_checks(&mut checks, &bundle, trust);
736
737    Ok(checks)
738}
739
740/// Tail of `verify_package`: emit leaf_count and timeline-order checks.
741/// Used by the early-return path when an unknown merkle version aborts
742/// Merkle recomputation — those two checks are independent of the tree
743/// version and still meaningful to surface.
744fn finish_package_checks(
745    mut checks: Vec<VerifyCheck>,
746    receipt: &SessionReceipt,
747) -> Vec<VerifyCheck> {
748    if receipt.merkle.leaf_count == receipt.artifacts.len() {
749        checks.push(VerifyCheck::pass(
750            "leaf_count",
751            "Leaf count matches artifact count",
752        ));
753    } else {
754        checks.push(VerifyCheck::fail(
755            "leaf_count",
756            &format!(
757                "leaf_count {} != artifact count {}",
758                receipt.merkle.leaf_count,
759                receipt.artifacts.len(),
760            ),
761        ));
762    }
763
764    let ordered = receipt.timeline.windows(2).all(|w| {
765        (&w[0].timestamp, w[0].sequence_no, &w[0].event_id)
766            <= (&w[1].timestamp, w[1].sequence_no, &w[1].event_id)
767    });
768    if ordered {
769        checks.push(VerifyCheck::pass(
770            "timeline_order",
771            "Timeline is correctly ordered",
772        ));
773    } else {
774        checks.push(VerifyCheck::fail(
775            "timeline_order",
776            "Timeline entries are not in deterministic order",
777        ));
778    }
779
780    checks
781}
782
783/// Emit the package-local + included-checkpoint replay checks. Both are
784/// fully offline: package-local scans the embedded uses for duplicates;
785/// included-checkpoint walks the embedded checkpoint records and
786/// re-derives each `record_digest` against its stored value.
787///
788/// The local-journal check is NOT here -- it requires workspace access
789/// and is added by the CLI wrapper in `commands/package.rs` that has the
790/// resolved config_path. Keeping these two pure means an offline tool
791/// (Hub-side validator, third-party verifier) can run the same checks
792/// without needing a Treeship workspace.
793pub(crate) fn add_approval_evidence_checks(
794    checks: &mut Vec<VerifyCheck>,
795    bundle: &ApprovalsBundle,
796    trust: &crate::trust::TrustRootStore,
797) {
798    if bundle.uses.is_empty() && bundle.checkpoints.is_empty() {
799        // Nothing to assert. Stay quiet rather than emit a "skipped"
800        // row -- session packages without approvals shouldn't drag in
801        // approval rows by accident.
802        return;
803    }
804
805    // -- replay-package-local --
806    // Two distinct violation cases inside the package:
807    //   (a) uses sharing (grant_id, nonce_digest) EXCEED max_uses on
808    //       that grant. Two uses of a max_uses=2 grant is fine; three
809    //       is the violation. max_uses is read from the use record's
810    //       own `max_uses` field (a snapshot from consume time).
811    //   (b) two ApprovalUse records with the same use_id -- a copy
812    //       artifact from a corrupt build, never legitimate.
813    use std::collections::HashMap;
814    let mut by_nonce: HashMap<(String, String), Vec<&ApprovalUse>> = HashMap::new();
815    let mut by_use_id: HashMap<&str, Vec<&ApprovalUse>> = HashMap::new();
816    for u in &bundle.uses {
817        by_nonce
818            .entry((u.grant_id.clone(), u.nonce_digest.clone()))
819            .or_default()
820            .push(u);
821        by_use_id.entry(&u.use_id).or_default().push(u);
822    }
823    let over_max: Vec<((String, String), Vec<&ApprovalUse>, u32)> = by_nonce
824        .iter()
825        .filter_map(|(key, uses)| {
826            let max = uses.iter().filter_map(|u| u.max_uses).next()?;
827            if (uses.len() as u32) > max {
828                Some((key.clone(), uses.iter().map(|u| *u).collect(), max))
829            } else {
830                None
831            }
832        })
833        .collect();
834    let dup_use_ids: Vec<(&&str, &Vec<&ApprovalUse>)> =
835        by_use_id.iter().filter(|(_, v)| v.len() > 1).collect();
836
837    if over_max.is_empty() && dup_use_ids.is_empty() {
838        checks.push(VerifyCheck::pass(
839            "replay-package-local",
840            &format!(
841                "no duplicate approval use inside package ({} uses scanned)",
842                bundle.uses.len()
843            ),
844        ));
845    } else {
846        let mut detail = String::from("package-local replay violation:");
847        for ((grant_id, _nd), uses, max) in &over_max {
848            detail.push_str(&format!(
849                " grant {grant_id} consumed {} times in this package (max_uses={max});",
850                uses.len(),
851            ));
852        }
853        for (uid, uses) in &dup_use_ids {
854            detail.push_str(&format!(" use_id {uid} appears {} times;", uses.len()));
855        }
856        checks.push(VerifyCheck::fail("replay-package-local", &detail));
857    }
858
859    // -- replay-included-checkpoint --
860    // For each checkpoint, recompute its record_digest from canonical
861    // form. If the stored digest doesn't match, the checkpoint was
862    // tampered after sealing.
863    if !bundle.checkpoints.is_empty() {
864        let mut tampered = Vec::new();
865        for cp in &bundle.checkpoints {
866            let recomputed = journal_checkpoint_record_digest(cp);
867            if recomputed != cp.record_digest {
868                tampered.push((
869                    cp.checkpoint_id.clone(),
870                    cp.record_digest.clone(),
871                    recomputed,
872                ));
873            }
874        }
875        if tampered.is_empty() {
876            checks.push(VerifyCheck::pass(
877                "replay-included-checkpoint",
878                &format!(
879                    "{} included journal checkpoint(s) verify offline",
880                    bundle.checkpoints.len()
881                ),
882            ));
883        } else {
884            let detail = tampered
885                .iter()
886                .map(|(id, expected, actual)| {
887                    format!("checkpoint {id} tampered (stored {expected}, recomputed {actual})")
888                })
889                .collect::<Vec<_>>()
890                .join("; ");
891            checks.push(VerifyCheck::fail("replay-included-checkpoint", &detail));
892        }
893    }
894
895    // -- approval-use-record-digest --
896    // Each ApprovalUse carries its own record_digest computed over the
897    // canonical form of the record (minus the digest itself). Tampering
898    // any field changes the digest. v0.9.10 PR A renames this from the
899    // older `approval-use-integrity` because the prior label suggested
900    // it covered nonce/action binding -- it didn't, and Codex's v0.9.9
901    // adversarial review flagged the over-claim. The honest scope of
902    // this row is "each use's stored digest matches its canonical
903    // recompute"; the binding checks are now separate rows below.
904    let mut tampered_uses = Vec::new();
905    for u in &bundle.uses {
906        let recomputed = approval_use_record_digest(u);
907        if recomputed != u.record_digest {
908            tampered_uses.push((u.use_id.clone(), u.record_digest.clone(), recomputed));
909        }
910    }
911    if !bundle.uses.is_empty() {
912        if tampered_uses.is_empty() {
913            checks.push(VerifyCheck::pass(
914                "approval-use-record-digest",
915                &format!("{} use record(s) recompute identically", bundle.uses.len()),
916            ));
917        } else {
918            let detail = tampered_uses
919                .iter()
920                .map(|(id, expected, actual)| {
921                    format!("use {id} tampered (stored {expected}, recomputed {actual})")
922                })
923                .collect::<Vec<_>>()
924                .join("; ");
925            checks.push(VerifyCheck::fail("approval-use-record-digest", &detail));
926        }
927    }
928
929    // -- approval-use-nonce-binding --
930    // Cross-check each use's `nonce_digest` against the corresponding
931    // grant's *signed* nonce. v0.9.9 trusted the use's nonce_digest
932    // verbatim, which let an attacker who controls the package mutate
933    // it (and recompute record_digest) to claim consumption of a grant
934    // whose nonce was never actually used. This row closes that gap.
935    //
936    // Discipline: the grant envelope is the source of truth. Before
937    // pulling the raw `nonce` from the grant's payload we verify the
938    // envelope's *content addressing* -- recompute the artifact_id
939    // from the envelope's PAE bytes and confirm it equals the grant_id
940    // the package claims. v0.9.10 PR A round 1 only parsed the
941    // envelope without this check; that left a forgery window where
942    // an attacker could ship an arbitrary unsigned envelope under any
943    // grant_id filename. v0.9.10 PR A round 2 closes the window: only
944    // a bytes-identical envelope produces the same artifact_id under
945    // SHA-256.
946    if !bundle.uses.is_empty() {
947        use crate::attestation::envelope::Envelope;
948        use crate::attestation::{artifact_id_from_pae, pae};
949        use crate::statements::{nonce_digest, ApprovalStatement};
950        let mut grant_nonce_digest: std::collections::HashMap<String, String> =
951            std::collections::HashMap::new();
952        let mut tampered_grants: Vec<String> = Vec::new();
953        for (grant_id, env_bytes) in &bundle.grants {
954            let env = match Envelope::from_json(env_bytes) {
955                Ok(e) => e,
956                Err(_) => {
957                    tampered_grants.push(format!("grant {grant_id} envelope unparseable"));
958                    continue;
959                }
960            };
961            // Content-addressing check: derive the artifact_id from
962            // the envelope's PAE bytes and confirm it matches the
963            // claimed grant_id. If they differ the envelope was
964            // substituted or its bytes were tampered post-sign.
965            let derived = match env.payload_bytes() {
966                Ok(p) => artifact_id_from_pae(&pae(&env.payload_type, &p)),
967                Err(_) => {
968                    tampered_grants.push(format!("grant {grant_id} envelope payload undecodable"));
969                    continue;
970                }
971            };
972            if &derived != grant_id {
973                tampered_grants.push(format!(
974                    "grant {grant_id} envelope content derives to {derived} -- envelope substituted or tampered",
975                ));
976                continue;
977            }
978            let approval: ApprovalStatement = match env.unmarshal_statement() {
979                Ok(a) => a,
980                Err(_) => {
981                    tampered_grants
982                        .push(format!("grant {grant_id} payload not an ApprovalStatement"));
983                    continue;
984                }
985            };
986            grant_nonce_digest.insert(grant_id.clone(), nonce_digest(&approval.nonce));
987        }
988        let mut mismatches: Vec<String> = Vec::new();
989        let mut missing_grants: Vec<String> = Vec::new();
990        for u in &bundle.uses {
991            match grant_nonce_digest.get(&u.grant_id) {
992                Some(expected) => {
993                    if expected != &u.nonce_digest {
994                        mismatches.push(format!(
995                            "use {} claims nonce_digest {} but grant {} signed nonce hashes to {}",
996                            u.use_id, u.nonce_digest, u.grant_id, expected,
997                        ));
998                    }
999                }
1000                None => {
1001                    missing_grants.push(format!(
1002                        "use {} references grant {} but no usable grant envelope is in the package",
1003                        u.use_id, u.grant_id,
1004                    ));
1005                }
1006            }
1007        }
1008        if mismatches.is_empty() && missing_grants.is_empty() && tampered_grants.is_empty() {
1009            checks.push(VerifyCheck::pass(
1010                "approval-use-nonce-binding",
1011                &format!(
1012                    "{} use record(s) bind to content-addressed grant signed nonces",
1013                    bundle.uses.len(),
1014                ),
1015            ));
1016        } else {
1017            let mut parts: Vec<String> = Vec::new();
1018            if !tampered_grants.is_empty() {
1019                parts.push(tampered_grants.join("; "));
1020            }
1021            if !mismatches.is_empty() {
1022                parts.push(mismatches.join("; "));
1023            }
1024            if !missing_grants.is_empty() {
1025                parts.push(missing_grants.join("; "));
1026            }
1027            checks.push(VerifyCheck::fail(
1028                "approval-use-nonce-binding",
1029                &parts.join("; "),
1030            ));
1031        }
1032    }
1033
1034    // -- approval-use-action-binding --
1035    // Cross-check each consuming action's `meta.approval_use_id`
1036    // against the package's use records. v0.9.9 ignored this pointer
1037    // entirely; the package didn't even ship action envelopes, so the
1038    // verifier could not see the field. v0.9.10 PR A: action envelopes
1039    // ride along in `artifacts/`, and this row pins that every action
1040    // declaring it consumed an approval has a use record for that
1041    // exact use_id, with matching grant_id and matching
1042    // `nonce_digest(approval_nonce)`.
1043    //
1044    // Honesty rule: when bundle.action_envelopes is empty (pre-v0.9.10
1045    // packages, or a v0.9.10 package with no consuming actions
1046    // recorded), this row reports `not asserted by package` rather
1047    // than silent PASS.
1048    if !bundle.uses.is_empty() {
1049        use crate::attestation::envelope::Envelope;
1050        use crate::attestation::{artifact_id_from_pae, pae};
1051        use crate::statements::{nonce_digest, ActionStatement};
1052        if bundle.action_envelopes.is_empty() {
1053            checks.push(VerifyCheck::warn(
1054                "approval-use-action-binding",
1055                "no action envelopes embedded -- action↔use binding not asserted by package (pre-v0.9.10)",
1056            ));
1057        } else {
1058            let use_ids: std::collections::HashSet<&str> =
1059                bundle.uses.iter().map(|u| u.use_id.as_str()).collect();
1060            let mut violations: Vec<String> = Vec::new();
1061            let mut bound_count = 0usize;
1062            for (artifact_id, env_bytes) in &bundle.action_envelopes {
1063                let env = match Envelope::from_json(env_bytes) {
1064                    Ok(e) => e,
1065                    Err(_) => {
1066                        violations.push(format!("action {artifact_id} envelope unparseable"));
1067                        continue;
1068                    }
1069                };
1070                // Content-addressing gate: derive the artifact_id
1071                // from the envelope's PAE bytes and require it to
1072                // match the filename stem the package shipped this
1073                // envelope under. Without this gate an attacker
1074                // controlling the package can write any forged
1075                // unsigned action JSON to artifacts/<id>.json and the
1076                // binding rows would trust it.
1077                let derived = match env.payload_bytes() {
1078                    Ok(p) => artifact_id_from_pae(&pae(&env.payload_type, &p)),
1079                    Err(_) => {
1080                        violations
1081                            .push(format!("action {artifact_id} envelope payload undecodable"));
1082                        continue;
1083                    }
1084                };
1085                if &derived != artifact_id {
1086                    violations.push(format!(
1087                        "action {artifact_id} envelope content derives to {derived} -- envelope substituted or tampered",
1088                    ));
1089                    continue;
1090                }
1091                let action: ActionStatement = match env.unmarshal_statement() {
1092                    Ok(a) => a,
1093                    Err(_) => {
1094                        violations.push(format!("action {artifact_id} not an ActionStatement"));
1095                        continue;
1096                    }
1097                };
1098                let raw_nonce = match action.approval_nonce.as_deref() {
1099                    Some(n) => n,
1100                    None => continue,
1101                };
1102                let claimed_use_id = action
1103                    .meta
1104                    .as_ref()
1105                    .and_then(|m| m.get("approval_use_id"))
1106                    .and_then(|v| v.as_str());
1107                let Some(claimed_use_id) = claimed_use_id else {
1108                    violations.push(format!(
1109                        "action {artifact_id} consumed an approval but its meta has no approval_use_id"
1110                    ));
1111                    continue;
1112                };
1113                if !use_ids.contains(claimed_use_id) {
1114                    violations.push(format!(
1115                        "action {artifact_id} claims approval_use_id={} but no such use is embedded",
1116                        claimed_use_id,
1117                    ));
1118                    continue;
1119                }
1120                let expected = nonce_digest(raw_nonce);
1121                let matched_use = bundle.uses.iter().find(|u| u.use_id == claimed_use_id);
1122                if let Some(u) = matched_use {
1123                    if u.nonce_digest != expected {
1124                        violations.push(format!(
1125                            "action {artifact_id} approval_nonce hashes to {} but use {} stores nonce_digest {}",
1126                            expected, claimed_use_id, u.nonce_digest,
1127                        ));
1128                        continue;
1129                    }
1130                }
1131                bound_count += 1;
1132            }
1133            if violations.is_empty() {
1134                checks.push(VerifyCheck::pass(
1135                    "approval-use-action-binding",
1136                    &format!(
1137                        "{bound_count} consuming action(s) bind cleanly to content-addressed envelope(s)",
1138                    ),
1139                ));
1140            } else {
1141                checks.push(VerifyCheck::fail(
1142                    "approval-use-action-binding",
1143                    &violations.join("; "),
1144                ));
1145            }
1146        }
1147    }
1148
1149    // -- approval-use-chain-continuity --
1150    // v0.9.9 verified each use's individual record_digest but never
1151    // walked the `previous_record_digest` chain across the embedded
1152    // records. An attacker could rewrite an entire chain consistently
1153    // (recomputing each digest along the way) and the per-record
1154    // checks all passed.
1155    //
1156    // Algorithm (v0.9.10 PR A round 2): build a graph of embedded
1157    // records keyed by record_digest, then require the embedded
1158    // records to form a SINGLE linked list with exactly one genesis
1159    // (previous_record_digest == "") and no cycles, forks, or
1160    // disconnected subchains.
1161    //
1162    //   - Dangling prev pointer (not in `owned`) -> fail.
1163    //   - More than one record with prev == ""    -> fail (mid-chain
1164    //     genesis is a forgery primitive).
1165    //   - Two records sharing the same prev       -> fail (fork).
1166    //   - Cycle reached during the walk           -> fail.
1167    //   - Walk doesn't reach every record         -> fail (disconnected
1168    //     subchain).
1169    //
1170    // We can only check *internal* consistency offline -- the package
1171    // doesn't ship the workspace journal's full history, so the chain
1172    // we see may be a contiguous prefix or window. Anchoring against
1173    // a Hub-signed checkpoint is replay-hub-org's job; here we report
1174    // structural consistency only.
1175    if !bundle.uses.is_empty() || !bundle.checkpoints.is_empty() {
1176        use std::collections::{HashMap, HashSet};
1177        // Each record carries a label for diagnostics + its own
1178        // record_digest + previous_record_digest.
1179        struct Node<'a> {
1180            label: String,
1181            digest: &'a str,
1182            prev: &'a str,
1183        }
1184        let mut nodes: Vec<Node> = Vec::new();
1185        for u in &bundle.uses {
1186            nodes.push(Node {
1187                label: format!("use {}", u.use_id),
1188                digest: u.record_digest.as_str(),
1189                prev: u.previous_record_digest.as_str(),
1190            });
1191        }
1192        for cp in &bundle.checkpoints {
1193            nodes.push(Node {
1194                label: format!("checkpoint {}", cp.checkpoint_id),
1195                digest: cp.record_digest.as_str(),
1196                prev: cp.previous_record_digest.as_str(),
1197            });
1198        }
1199
1200        let owned: HashSet<&str> = std::iter::once("")
1201            .chain(nodes.iter().map(|n| n.digest))
1202            .collect();
1203
1204        let mut violations: Vec<String> = Vec::new();
1205        // Dangling prev: pointer not in owned set.
1206        for n in &nodes {
1207            if !owned.contains(n.prev) {
1208                violations.push(format!(
1209                    "{} previous_record_digest {} not anchored in package",
1210                    n.label, n.prev,
1211                ));
1212            }
1213        }
1214        // Genesis count: only one record allowed to have prev == "".
1215        let genesis: Vec<&Node> = nodes.iter().filter(|n| n.prev.is_empty()).collect();
1216        if genesis.len() > 1 {
1217            violations.push(format!(
1218                "{} records claim previous_record_digest='' (genesis): {}",
1219                genesis.len(),
1220                genesis
1221                    .iter()
1222                    .map(|n| n.label.clone())
1223                    .collect::<Vec<_>>()
1224                    .join(", "),
1225            ));
1226        }
1227        // Forks: two records sharing the same non-empty prev.
1228        let mut by_prev: HashMap<&str, Vec<&Node>> = HashMap::new();
1229        for n in &nodes {
1230            by_prev.entry(n.prev).or_default().push(n);
1231        }
1232        for (prev, group) in &by_prev {
1233            if group.len() > 1 && !prev.is_empty() {
1234                violations.push(format!(
1235                    "fork: {} records share previous_record_digest {}: {}",
1236                    group.len(),
1237                    prev,
1238                    group
1239                        .iter()
1240                        .map(|n| n.label.clone())
1241                        .collect::<Vec<_>>()
1242                        .join(", "),
1243                ));
1244            }
1245        }
1246
1247        // Walk from genesis (if exactly one) following digest-as-prev
1248        // links. Detect cycles and unreachable records.
1249        if violations.is_empty() {
1250            let by_digest: HashMap<&str, &Node> = nodes.iter().map(|n| (n.digest, n)).collect();
1251            let next_of: HashMap<&str, &Node> = nodes
1252                .iter()
1253                .filter(|n| !n.prev.is_empty())
1254                .map(|n| (n.prev, *(&n)))
1255                .collect();
1256            let start = match genesis.first() {
1257                Some(g) => Some(*g),
1258                None => None,
1259            };
1260            let mut visited: HashSet<&str> = HashSet::new();
1261            let mut current = start;
1262            while let Some(node) = current {
1263                if !visited.insert(node.digest) {
1264                    violations.push(format!(
1265                        "cycle detected at {} (record_digest {})",
1266                        node.label, node.digest,
1267                    ));
1268                    break;
1269                }
1270                current = next_of.get(node.digest).copied();
1271            }
1272            // Disconnected: walk didn't include every node.
1273            if violations.is_empty() && visited.len() != nodes.len() {
1274                let unreached: Vec<String> = nodes
1275                    .iter()
1276                    .filter(|n| !visited.contains(n.digest))
1277                    .map(|n| n.label.clone())
1278                    .collect();
1279                if !unreached.is_empty() {
1280                    violations.push(format!(
1281                        "disconnected subchain: {} record(s) not reachable from genesis: {}",
1282                        unreached.len(),
1283                        unreached.join(", "),
1284                    ));
1285                }
1286            }
1287            let _ = by_digest; // reserved for future cross-checks
1288        }
1289
1290        if violations.is_empty() {
1291            checks.push(VerifyCheck::pass(
1292                "approval-use-chain-continuity",
1293                &format!(
1294                    "{} record(s) form a single connected linked list from one genesis with no cycles or forks",
1295                    nodes.len(),
1296                ),
1297            ));
1298        } else {
1299            checks.push(VerifyCheck::fail(
1300                "approval-use-chain-continuity",
1301                &violations.join("; "),
1302            ));
1303        }
1304    }
1305
1306    // -- replay-hub-org -- v0.9.9 PR 6.
1307    // The strongest level Treeship can speak to today. The release
1308    // rule is non-negotiable: PASS only when (1) at least one embedded
1309    // checkpoint declares kind=HubOrg, (2) every required Hub field is
1310    // populated, (3) the signature verifies against the embedded
1311    // public key, AND (4) the checkpoint covers every embedded
1312    // ApprovalUse via covered_use_ids. Anything short of that means
1313    // "no row" or "fail" -- never silent pass.
1314    //
1315    // No row at all when the package has no Hub-kind checkpoint:
1316    // matches the v0.9.9 PR 4-5 behavior where the panel renders
1317    // "- hub-org   not checked (no Hub checkpoint in package)" so a
1318    // reader doesn't misread an absent row as a failure.
1319    let hub_checkpoints: Vec<&JournalCheckpoint> = bundle
1320        .checkpoints
1321        .iter()
1322        .filter(|cp| cp.checkpoint_kind == crate::statements::CheckpointKind::HubOrg)
1323        .collect();
1324    if !hub_checkpoints.is_empty() {
1325        let mut all_ok = true;
1326        let mut details: Vec<String> = Vec::new();
1327        let mut have_valid_signature = false;
1328        // Security-critical failures (untrusted-issuer / tampered /
1329        // not-hub-kind) must FAIL unconditionally, not warn. Audit
1330        // lane J fix-up: previously these emitted WARN and the CLI
1331        // wrapper's --strict promoted to FAIL, which meant the
1332        // headline audit case (self-signed hub-org forgery) passed
1333        // green-but-yellow in default mode. The release rule is
1334        // "trust pinning is on by default"; expressed in this row
1335        // as "any signature/issuer failure is a hard fail."
1336        let mut security_fatal = false;
1337
1338        for cp in &hub_checkpoints {
1339            match crate::statements::verify_hub_checkpoint_signature(cp, trust) {
1340                crate::statements::HubCheckpointVerification::Valid => {
1341                    have_valid_signature = true;
1342                    // Coverage: every embedded use_id MUST appear in
1343                    // this checkpoint's covered_use_ids. A checkpoint
1344                    // that doesn't cover the package's uses cannot
1345                    // promote replay-hub-org for those uses.
1346                    let covered: std::collections::HashSet<&String> =
1347                        cp.covered_use_ids.iter().collect();
1348                    let missing: Vec<String> = bundle
1349                        .uses
1350                        .iter()
1351                        .filter(|u| !covered.contains(&u.use_id))
1352                        .map(|u| u.use_id.clone())
1353                        .collect();
1354                    if missing.is_empty() {
1355                        details.push(format!(
1356                            "{} signed by {} verifies; covers {} use(s)",
1357                            cp.checkpoint_id,
1358                            cp.hub_id,
1359                            cp.covered_use_ids.len(),
1360                        ));
1361                    } else {
1362                        all_ok = false;
1363                        details.push(format!(
1364                            "{} verifies but does not cover {} use(s): {}",
1365                            cp.checkpoint_id,
1366                            missing.len(),
1367                            missing.join(", "),
1368                        ));
1369                    }
1370                }
1371                crate::statements::HubCheckpointVerification::MissingFields(field) => {
1372                    all_ok = false;
1373                    details.push(format!(
1374                        "{} declares kind=hub-org but field `{}` is missing",
1375                        cp.checkpoint_id, field,
1376                    ));
1377                }
1378                crate::statements::HubCheckpointVerification::Tampered => {
1379                    all_ok = false;
1380                    security_fatal = true;
1381                    details.push(format!(
1382                        "{} hub signature failed verification (tampered or wrong key)",
1383                        cp.checkpoint_id,
1384                    ));
1385                }
1386                crate::statements::HubCheckpointVerification::NotHubKind => {
1387                    // Filter ensures this is unreachable; keep the
1388                    // arm so a future filter relaxation doesn't go
1389                    // silent.
1390                    all_ok = false;
1391                    security_fatal = true;
1392                    details.push(format!(
1393                        "{} kind toggled out of hub-org during verify",
1394                        cp.checkpoint_id,
1395                    ));
1396                }
1397                crate::statements::HubCheckpointVerification::UntrustedIssuer => {
1398                    all_ok = false;
1399                    security_fatal = true;
1400                    details.push(format!(
1401                        "{} hub_public_key is not a trusted root (configure via `treeship trust add`)",
1402                        cp.checkpoint_id,
1403                    ));
1404                }
1405            }
1406        }
1407        if all_ok && have_valid_signature {
1408            checks.push(VerifyCheck::pass("replay-hub-org", &details.join("; ")));
1409        } else if security_fatal {
1410            // Untrusted issuer or tampered signature: fail-by-default
1411            // regardless of --strict. Self-signed forgeries must not
1412            // pass yellow.
1413            checks.push(VerifyCheck::fail("replay-hub-org", &details.join("; ")));
1414        } else {
1415            // Hub checkpoint is present but does not satisfy every
1416            // non-security gate (missing-field, coverage gap).
1417            // Default mode warns; the CLI verify wrapper's --strict
1418            // promotes to fail.
1419            checks.push(VerifyCheck::warn("replay-hub-org", &details.join("; ")));
1420        }
1421    }
1422    // No hub-org checkpoints embedded -> no row. The Approval
1423    // Authority panel still renders "- hub-org   not checked".
1424
1425    let _ = ReplayCheckLevel::HubOrg;
1426    let _ = approval_revocation_record_digest as fn(&ApprovalRevocation) -> String;
1427    let _ = ReplayCheck::not_performed;
1428}
1429
1430/// A single verification check result.
1431#[derive(Debug, Clone)]
1432pub struct VerifyCheck {
1433    pub name: String,
1434    pub status: VerifyStatus,
1435    pub detail: String,
1436}
1437
1438/// Status of a verification check.
1439#[derive(Debug, Clone, PartialEq, Eq)]
1440pub enum VerifyStatus {
1441    Pass,
1442    Fail,
1443    Warn,
1444}
1445
1446impl VerifyCheck {
1447    pub fn pass(name: &str, detail: &str) -> Self {
1448        Self {
1449            name: name.into(),
1450            status: VerifyStatus::Pass,
1451            detail: detail.into(),
1452        }
1453    }
1454    pub fn fail(name: &str, detail: &str) -> Self {
1455        Self {
1456            name: name.into(),
1457            status: VerifyStatus::Fail,
1458            detail: detail.into(),
1459        }
1460    }
1461    pub fn warn(name: &str, detail: &str) -> Self {
1462        Self {
1463            name: name.into(),
1464            status: VerifyStatus::Warn,
1465            detail: detail.into(),
1466        }
1467    }
1468}
1469
1470impl VerifyCheck {
1471    pub fn passed(&self) -> bool {
1472        self.status == VerifyStatus::Pass
1473    }
1474}
1475
1476/// HTML template for the self-contained verifier preview.
1477/// Loaded at compile time so the binary carries no runtime file dependencies.
1478const PREVIEW_TEMPLATE: &str = include_str!("preview_template.html");
1479
1480/// Generate a self-contained preview.html that embeds the receipt JSON
1481/// and runs Merkle verification client-side using Web Crypto API.
1482///
1483/// The HTML works fully air-gapped: no network calls, no CDN, no server.
1484/// Open it in any modern browser and it automatically verifies the receipt
1485/// and shows pass/fail for each check.
1486pub fn render_preview_html(receipt: &SessionReceipt) -> String {
1487    let receipt_json = serde_json::to_string_pretty(receipt).unwrap_or_else(|_| "{}".to_string());
1488    // Defense-in-depth: escape </script sequences so a malicious receipt
1489    // field cannot break out of the JSON data block. The primary defense
1490    // is type="application/json" which the HTML parser does not execute,
1491    // but this escaping adds a second layer.
1492    // Escape ALL '<' as '\u003c' in the JSON string to prevent any
1493    // case-variant of </script> from breaking out of the data block.
1494    // This is bulletproof: no HTML parser can see a tag open inside the JSON.
1495    let safe_json = receipt_json.replace('<', r"\u003c");
1496
1497    // The only placeholder that must take the receipt JSON is the data
1498    // block. replacen(.., 1) substitutes exactly that first occurrence, so
1499    // even if the token is ever reused elsewhere in the template (e.g. a JS
1500    // placeholder check) the receipt body is never injected into it. The
1501    // template's own placeholder check uses a split sentinel for the same
1502    // reason. The page title is set at runtime from the parsed JSON.
1503    PREVIEW_TEMPLATE.replacen("__RECEIPT_JSON__", &safe_json, 1)
1504}
1505
1506#[cfg(test)]
1507mod tests {
1508    use super::*;
1509    use crate::session::event::*;
1510    use crate::session::manifest::SessionManifest;
1511    use crate::session::receipt::{ArtifactEntry, ReceiptComposer};
1512
1513    fn make_receipt() -> SessionReceipt {
1514        let manifest = SessionManifest::new(
1515            "ssn_pkg_test".into(),
1516            "agent://test".into(),
1517            "2026-04-05T08:00:00Z".into(),
1518            1743843600000,
1519        );
1520
1521        let mk = |seq: u64, inst: &str, et: EventType| -> SessionEvent {
1522            SessionEvent {
1523                session_id: "ssn_pkg_test".into(),
1524                event_id: format!("evt_{:016x}", seq),
1525                timestamp: format!("2026-04-05T08:{:02}:00Z", seq),
1526                sequence_no: seq,
1527                trace_id: "trace_1".into(),
1528                span_id: format!("span_{seq}"),
1529                parent_span_id: None,
1530                agent_id: format!("agent://{inst}"),
1531                agent_instance_id: inst.into(),
1532                agent_name: inst.into(),
1533                agent_role: None,
1534                host_id: "host_1".into(),
1535                tool_runtime_id: None,
1536                event_type: et,
1537                artifact_ref: None,
1538                meta: None,
1539            }
1540        };
1541
1542        let events = vec![
1543            mk(0, "root", EventType::SessionStarted),
1544            mk(
1545                1,
1546                "root",
1547                EventType::AgentStarted {
1548                    parent_agent_instance_id: None,
1549                },
1550            ),
1551            mk(
1552                2,
1553                "root",
1554                EventType::AgentCalledTool {
1555                    tool_name: "read_file".into(),
1556                    tool_input_digest: None,
1557                    tool_output_digest: None,
1558                    duration_ms: Some(10),
1559                },
1560            ),
1561            mk(
1562                3,
1563                "root",
1564                EventType::AgentCompleted {
1565                    termination_reason: None,
1566                },
1567            ),
1568            mk(
1569                4,
1570                "root",
1571                EventType::SessionClosed {
1572                    summary: Some("Done".into()),
1573                    duration_ms: Some(60000),
1574                },
1575            ),
1576        ];
1577
1578        let artifacts = vec![ArtifactEntry {
1579            artifact_id: "art_001".into(),
1580            payload_type: "action".into(),
1581            digest: None,
1582            signed_at: None,
1583        }];
1584
1585        ReceiptComposer::compose(&manifest, &events, artifacts)
1586    }
1587
1588    #[test]
1589    fn build_and_read_package() {
1590        let receipt = make_receipt();
1591        let tmp = std::env::temp_dir().join(format!("treeship-pkg-test-{}", rand::random::<u32>()));
1592
1593        let output = build_package(&receipt, &tmp).unwrap();
1594        assert!(output.path.exists());
1595        assert!(output.path.join("receipt.json").exists());
1596        assert!(output.path.join("merkle.json").exists());
1597        assert!(output.path.join("render.json").exists());
1598        assert!(output.path.join("preview.html").exists());
1599        assert!(output.receipt_digest.starts_with("sha256:"));
1600        assert!(output.file_count >= 4);
1601
1602        // Read back
1603        let read_back = read_package(&output.path).unwrap();
1604        assert_eq!(read_back.session.id, "ssn_pkg_test");
1605        assert_eq!(read_back.type_, RECEIPT_TYPE);
1606
1607        let _ = std::fs::remove_dir_all(&tmp);
1608    }
1609
1610    #[test]
1611    fn verify_valid_package() {
1612        let receipt = make_receipt();
1613        let tmp =
1614            std::env::temp_dir().join(format!("treeship-pkg-verify-{}", rand::random::<u32>()));
1615
1616        let output = build_package(&receipt, &tmp).unwrap();
1617        let checks = verify_package(&output.path).unwrap();
1618
1619        let fails: Vec<_> = checks
1620            .iter()
1621            .filter(|c| c.status == VerifyStatus::Fail)
1622            .collect();
1623        assert!(fails.is_empty(), "unexpected failures: {fails:?}");
1624
1625        let passes: Vec<_> = checks
1626            .iter()
1627            .filter(|c| c.status == VerifyStatus::Pass)
1628            .collect();
1629        assert!(
1630            passes.len() >= 5,
1631            "expected at least 5 pass checks, got {}",
1632            passes.len()
1633        );
1634
1635        let _ = std::fs::remove_dir_all(&tmp);
1636    }
1637
1638    // AUD-07: a receipt stamped reconcile_degraded must surface a WARN on
1639    // verify, so a consumer is told the file ledger may be incomplete rather
1640    // than reading the package as a clean, complete audit trail.
1641    #[test]
1642    fn verify_warns_when_reconcile_degraded() {
1643        let mut receipt = make_receipt();
1644        receipt.proofs.reconcile_degraded = true;
1645        let tmp =
1646            std::env::temp_dir().join(format!("treeship-pkg-degraded-{}", rand::random::<u32>()));
1647
1648        let output = build_package(&receipt, &tmp).unwrap();
1649        let checks = verify_package(&output.path).unwrap();
1650
1651        let warned = checks
1652            .iter()
1653            .any(|c| c.name == "reconcile_degraded" && c.status == VerifyStatus::Warn);
1654        assert!(warned, "expected a reconcile_degraded WARN, got {checks:?}");
1655        // It is a WARN, not a hard fail (the signatures/Merkle are still valid).
1656        let fails: Vec<_> = checks
1657            .iter()
1658            .filter(|c| c.status == VerifyStatus::Fail)
1659            .collect();
1660        assert!(fails.is_empty(), "must not hard-fail: {fails:?}");
1661
1662        let _ = std::fs::remove_dir_all(&tmp);
1663    }
1664
1665    #[test]
1666    fn verify_no_degraded_warn_when_clean() {
1667        // The default receipt has reconcile_degraded=false: no such WARN.
1668        let receipt = make_receipt();
1669        let tmp =
1670            std::env::temp_dir().join(format!("treeship-pkg-clean-{}", rand::random::<u32>()));
1671        let output = build_package(&receipt, &tmp).unwrap();
1672        let checks = verify_package(&output.path).unwrap();
1673        assert!(
1674            !checks.iter().any(|c| c.name == "reconcile_degraded"),
1675            "clean receipt must not emit a reconcile_degraded check"
1676        );
1677        let _ = std::fs::remove_dir_all(&tmp);
1678    }
1679
1680    #[test]
1681    fn verify_detects_missing_receipt() {
1682        let tmp =
1683            std::env::temp_dir().join(format!("treeship-pkg-empty-{}", rand::random::<u32>()));
1684        std::fs::create_dir_all(&tmp).unwrap();
1685
1686        let err = read_package(&tmp);
1687        assert!(err.is_err());
1688
1689        let _ = std::fs::remove_dir_all(&tmp);
1690    }
1691
1692    #[test]
1693    fn preview_html_contains_session_info() {
1694        let receipt = make_receipt();
1695        let html = render_preview_html(&receipt);
1696        assert!(html.contains("ssn_pkg_test"));
1697        assert!(html.contains("treeship.dev"));
1698        assert!(html.contains("Timeline"));
1699
1700        // Regression: the receipt JSON must land ONLY in the data block,
1701        // never in the inline JS. A prior bug used replace() (all matches)
1702        // against a template that carried the placeholder token twice (data
1703        // slot + a JS placeholder check), injecting the receipt body into a
1704        // JS string literal. That produced an uncaught SyntaxError, so the
1705        // whole script never ran and the preview hung on "Verifying
1706        // receipt...". The JS check now uses a split sentinel that must
1707        // survive substitution verbatim, and replacen(.., 1) fills only the
1708        // first occurrence.
1709        assert!(
1710            html.contains("'__RECEIPT'+'_JSON__'"),
1711            "JS placeholder check was clobbered by the receipt substitution",
1712        );
1713        assert!(
1714            !html.contains("application/json\">__RECEIPT_JSON__</script>"),
1715            "data slot was not substituted with the receipt JSON",
1716        );
1717        // The session id (a receipt value) must appear inside the data block,
1718        // not leak into executable JS, so a quick structural sanity check:
1719        // there is exactly one unsubstituted token left at most (none here).
1720        assert_eq!(
1721            html.matches("__RECEIPT_JSON__").count(),
1722            0,
1723            "no raw placeholder token should remain after substitution",
1724        );
1725    }
1726}