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