Skip to main content

lean_ctx/core/
handoff_transfer_bundle.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use std::path::Path;
4
5use crate::core::handoff_ledger::HandoffLedgerV1;
6
7const MAX_BUNDLE_BYTES: usize = 350_000;
8const MAX_PROOF_FILES: usize = 50;
9const MAX_ARTIFACT_ITEMS: usize = 80;
10const MAX_LEDGER_SNAPSHOT_CHARS: usize = 80_000;
11const MAX_CURATED_REF_CHARS: usize = 20_000;
12const MAX_DECISION_CHARS: usize = 2_000;
13const MAX_FINDING_CHARS: usize = 2_000;
14const MAX_NEXT_STEP_CHARS: usize = 1_000;
15const MAX_TASK_CHARS: usize = 4_000;
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum BundlePrivacyV1 {
19    Redacted,
20    Full,
21}
22
23impl BundlePrivacyV1 {
24    pub fn parse(s: Option<&str>) -> Self {
25        match s.unwrap_or("redacted").trim().to_lowercase().as_str() {
26            "full" => Self::Full,
27            _ => Self::Redacted,
28        }
29    }
30
31    pub(crate) fn as_str(&self) -> &'static str {
32        match self {
33            Self::Redacted => "redacted",
34            Self::Full => "full",
35        }
36    }
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct HandoffTransferBundleV1 {
41    pub schema_version: u32,
42    pub exported_at: DateTime<Utc>,
43    pub privacy: String,
44    pub project: ProjectIdentityV1,
45    pub ledger: HandoffLedgerV1,
46    pub artifacts: ArtifactsExcerptV1,
47    #[serde(default, skip_serializing_if = "Option::is_none")]
48    pub signature: Option<String>,
49    #[serde(default, skip_serializing_if = "Option::is_none")]
50    pub signer_public_key: Option<String>,
51    #[serde(default, skip_serializing_if = "Option::is_none")]
52    pub signer_agent_id: Option<String>,
53    /// Signature scheme. [`BOUND_SIGNATURE_SCHEME`] covers `signer_agent_id`
54    /// and `signer_public_key` (GHSA-gvg8-285q-m62g); absent means a legacy
55    /// signature over the payload only, whose signer id is not bound.
56    #[serde(default, skip_serializing_if = "Option::is_none")]
57    pub signature_scheme: Option<String>,
58}
59
60/// Signature over the whole bundle except `signature` itself.
61pub const BOUND_SIGNATURE_SCHEME: &str = "ed25519-bound-v2";
62
63#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct ProjectIdentityV1 {
65    pub project_root_hash: Option<String>,
66    pub project_identity_hash: Option<String>,
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize, Default)]
70pub struct ArtifactsExcerptV1 {
71    pub resolved: Vec<crate::core::artifacts::ResolvedArtifact>,
72    pub proof_files: Vec<ProofFileV1>,
73    pub warnings: Vec<String>,
74}
75
76#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct ProofFileV1 {
78    pub name: String,
79    pub md5: String,
80    pub bytes: u64,
81}
82
83pub fn build_bundle_v1(
84    mut ledger: HandoffLedgerV1,
85    project_root: Option<&str>,
86    privacy: BundlePrivacyV1,
87) -> HandoffTransferBundleV1 {
88    let role_name = crate::core::roles::active_role_name();
89    let effective_privacy = match privacy {
90        BundlePrivacyV1::Full
91            if role_name == "admin"
92                && !crate::core::redaction::redaction_enabled_for_active_role() =>
93        {
94            BundlePrivacyV1::Full
95        }
96        _ => BundlePrivacyV1::Redacted,
97    };
98
99    let (project_root_hash, project_identity_hash) = project_root.map_or((None, None), |root| {
100        let root_hash = crate::core::project_hash::hash_project_root(root);
101        let identity = crate::core::project_hash::project_identity(root);
102        let identity_hash = identity.as_deref().map(crate::core::hasher::hash_str);
103        (Some(root_hash), identity_hash)
104    });
105
106    cap_ledger_in_place(&mut ledger);
107
108    match effective_privacy {
109        BundlePrivacyV1::Full => {}
110        BundlePrivacyV1::Redacted => {
111            redact_ledger_in_place(&mut ledger);
112        }
113    }
114
115    // Keep embedded ledger internally consistent.
116    ledger.content_md5 = crate::core::handoff_ledger::compute_content_md5_for_ledger(&ledger);
117
118    let artifacts = project_root
119        .map(Path::new)
120        .map(build_artifacts_excerpt_v1)
121        .unwrap_or_default();
122
123    // Built unsigned by design: signing is the exporter's responsibility
124    // (`ctx_handoff export` signs with the real agent identity, GL #465).
125    // The old implicit role-name signing here conflated role with identity
126    // and silently swallowed failures.
127    HandoffTransferBundleV1 {
128        schema_version: crate::core::contracts::HANDOFF_TRANSFER_BUNDLE_V1_SCHEMA_VERSION,
129        exported_at: Utc::now(),
130        privacy: effective_privacy.as_str().to_string(),
131        project: ProjectIdentityV1 {
132            project_root_hash,
133            project_identity_hash,
134        },
135        ledger,
136        artifacts,
137        signature: None,
138        signer_public_key: None,
139        signer_agent_id: None,
140        signature_scheme: None,
141    }
142}
143
144pub fn sign_bundle(bundle: &mut HandoffTransferBundleV1, agent_id: &str) -> Result<(), String> {
145    // One atomic key resolution — signature and embedded public key must come
146    // from the same keypair (two separate lookups raced with concurrent
147    // data-dir changes and produced unverifiable bundles).
148    let key = crate::core::agent_identity::get_or_create_keypair(agent_id)?;
149
150    // GHSA-gvg8-285q-m62g: signer id, public key and scheme are part of the
151    // signed payload, so relabelling the signer breaks the signature.
152    bundle.signature = None;
153    bundle.signer_public_key = Some(crate::core::agent_identity::hex_encode(
154        &key.verifying_key().to_bytes(),
155    ));
156    bundle.signer_agent_id = Some(agent_id.to_string());
157    bundle.signature_scheme = Some(BOUND_SIGNATURE_SCHEME.to_string());
158
159    let canonical =
160        serde_json::to_string(bundle).map_err(|e| format!("serialize for signing: {e}"))?;
161    let sig_bytes = crate::core::agent_identity::sign_bytes_with(&key, canonical.as_bytes());
162    bundle.signature = Some(crate::core::agent_identity::hex_encode(&sig_bytes));
163    Ok(())
164}
165
166/// A signature that verified against the bundle's embedded public key.
167///
168/// Integrity only: the key travels inside the bundle, so `claimed_signer` is
169/// an identity *claim* unless `trusted` is set (GHSA-gvg8-285q-m62g).
170#[derive(Debug, Clone, PartialEq, Eq)]
171pub struct VerifiedSignature {
172    /// The bundle's `signer_agent_id`.
173    pub claimed_signer: String,
174    /// Fingerprint of the embedded public key (see `signer_trust::key_fingerprint`).
175    pub key_fingerprint: String,
176    /// `signer_agent_id` is covered by the signature (bound scheme). Legacy
177    /// signatures leave it unbound, so anyone can relabel the signer.
178    pub signer_bound: bool,
179    /// The key is anchored for `claimed_signer` (pinned in the signer trust
180    /// store, or held by a local agent identity). Never set when unbound.
181    pub trusted: bool,
182}
183
184impl VerifiedSignature {
185    /// Human-readable status that never claims identity without an anchor.
186    pub fn describe(&self) -> String {
187        let fp = &self.key_fingerprint;
188        let who = &self.claimed_signer;
189        if self.trusted {
190            format!("verified (signer={who}, key {fp}, trusted)")
191        } else if self.signer_bound {
192            format!("integrity ok, signer key {fp} (untrusted; claimed signer={who})")
193        } else {
194            format!(
195                "integrity ok, legacy signature (signer id not covered), signer key {fp} \
196                 (untrusted; claimed signer={who})"
197            )
198        }
199    }
200
201    /// Stable machine status for JSON responses.
202    #[cfg(any(test, feature = "http-server"))]
203    pub fn status(&self) -> &'static str {
204        if self.trusted {
205            "verified"
206        } else if self.signer_bound {
207            "untrusted_signer"
208        } else {
209            "legacy_unbound"
210        }
211    }
212}
213
214/// Outcome of checking a bundle's embedded Ed25519 signature on import
215/// (GL #465). Fail-closed semantics: any *present-but-broken* signature
216/// material is [`Invalid`](BundleSignatureStatus::Invalid) and must block the
217/// import; only the complete absence of all signature fields counts as
218/// [`Unsigned`](BundleSignatureStatus::Unsigned).
219#[derive(Debug, Clone, PartialEq, Eq)]
220pub enum BundleSignatureStatus {
221    /// Signature verifies against the embedded key; see [`VerifiedSignature`]
222    /// for whether the signer identity is bound and trusted.
223    Signed(VerifiedSignature),
224    /// No signature fields at all.
225    Unsigned,
226    /// Signature material present but not verifiable — reject the bundle.
227    Invalid(String),
228}
229
230/// Classify a bundle's signature for import-time enforcement (GL #465).
231#[must_use]
232pub fn check_bundle_signature(bundle: &HandoffTransferBundleV1) -> BundleSignatureStatus {
233    if bundle.signature.is_none()
234        && bundle.signer_public_key.is_none()
235        && bundle.signer_agent_id.is_none()
236        && bundle.signature_scheme.is_none()
237    {
238        return BundleSignatureStatus::Unsigned;
239    }
240    match verify_bundle_signature(bundle) {
241        Ok(verified) => BundleSignatureStatus::Signed(verified),
242        Err(e) => BundleSignatureStatus::Invalid(e),
243    }
244}
245
246pub(crate) fn verify_bundle_signature(
247    bundle: &HandoffTransferBundleV1,
248) -> Result<VerifiedSignature, String> {
249    let sig_hex = bundle
250        .signature
251        .as_deref()
252        .ok_or_else(|| "bundle has no signature".to_string())?;
253    let pk_hex = bundle
254        .signer_public_key
255        .as_deref()
256        .ok_or_else(|| "bundle has no signer_public_key".to_string())?;
257    let agent_id = bundle
258        .signer_agent_id
259        .as_deref()
260        .ok_or_else(|| "bundle has no signer_agent_id".to_string())?;
261
262    let sig_bytes = crate::core::agent_identity::hex_decode(sig_hex)?;
263    let pk_bytes = crate::core::agent_identity::hex_decode(pk_hex)?;
264
265    let mut verify_bundle = bundle.clone();
266    verify_bundle.signature = None;
267    let signer_bound = match bundle.signature_scheme.as_deref() {
268        Some(BOUND_SIGNATURE_SCHEME) => true,
269        None => {
270            // Legacy scheme: signed before the signer fields were filled in.
271            verify_bundle.signer_public_key = None;
272            verify_bundle.signer_agent_id = None;
273            false
274        }
275        Some(other) => return Err(format!("unsupported signature scheme `{other}`")),
276    };
277
278    let canonical =
279        serde_json::to_string(&verify_bundle).map_err(|e| format!("serialize for verify: {e}"))?;
280
281    if !crate::core::agent_identity::verify_signature(&pk_bytes, canonical.as_bytes(), &sig_bytes) {
282        return Err("signature verification failed".to_string());
283    }
284    Ok(VerifiedSignature {
285        claimed_signer: agent_id.to_string(),
286        key_fingerprint: crate::core::signer_trust::key_fingerprint(pk_hex),
287        signer_bound,
288        trusted: signer_bound && crate::core::signer_trust::is_trusted(agent_id, pk_hex),
289    })
290}
291
292pub fn serialize_bundle_v1_pretty(bundle: &HandoffTransferBundleV1) -> Result<String, String> {
293    let json = serde_json::to_string_pretty(bundle).map_err(|e| e.to_string())?;
294    if json.len() > MAX_BUNDLE_BYTES {
295        return Err(format!(
296            "ERROR: bundle too large ({} bytes > max {}). Use privacy=redacted and/or reduce curated refs.",
297            json.len(),
298            MAX_BUNDLE_BYTES
299        ));
300    }
301    Ok(json)
302}
303
304pub fn parse_bundle_v1(json: &str) -> Result<HandoffTransferBundleV1, String> {
305    let b: HandoffTransferBundleV1 = serde_json::from_str(json).map_err(|e| e.to_string())?;
306    if b.schema_version != crate::core::contracts::HANDOFF_TRANSFER_BUNDLE_V1_SCHEMA_VERSION {
307        return Err(format!(
308            "ERROR: unsupported schema_version {} (expected {})",
309            b.schema_version,
310            crate::core::contracts::HANDOFF_TRANSFER_BUNDLE_V1_SCHEMA_VERSION
311        ));
312    }
313    Ok(b)
314}
315
316pub fn write_bundle_v1(path: &Path, json: &str) -> Result<(), String> {
317    let parent = path
318        .parent()
319        .ok_or_else(|| "ERROR: invalid path".to_string())?;
320    if !parent.exists() {
321        std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
322    }
323    let tmp = parent.join(format!(
324        ".{}.tmp",
325        path.file_name()
326            .and_then(|s| s.to_str())
327            .unwrap_or("bundle")
328    ));
329    std::fs::write(&tmp, json).map_err(|e| e.to_string())?;
330    std::fs::rename(&tmp, path).map_err(|e| e.to_string())?;
331    Ok(())
332}
333
334pub fn read_bundle_v1(path: &Path) -> Result<HandoffTransferBundleV1, String> {
335    let json = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
336    if json.len() > MAX_BUNDLE_BYTES {
337        return Err(format!(
338            "ERROR: bundle file too large ({} bytes > max {})",
339            json.len(),
340            MAX_BUNDLE_BYTES
341        ));
342    }
343    parse_bundle_v1(&json)
344}
345
346pub fn project_identity_warning(
347    bundle: &HandoffTransferBundleV1,
348    project_root: &str,
349) -> Option<String> {
350    let current_root_hash = crate::core::project_hash::hash_project_root(project_root);
351    let current_identity_hash = crate::core::project_hash::project_identity(project_root)
352        .as_deref()
353        .map(crate::core::hasher::hash_str);
354
355    if let Some(ref exported) = bundle.project.project_root_hash
356        && exported != &current_root_hash
357    {
358        return Some(
359            "WARNING: project_root_hash mismatch (importing into different project root)."
360                .to_string(),
361        );
362    }
363
364    if let (Some(exported), Some(current)) = (
365        bundle.project.project_identity_hash.as_ref(),
366        current_identity_hash.as_ref(),
367    ) && exported != current
368    {
369        return Some(
370            "WARNING: project_identity_hash mismatch (importing into different project identity)."
371                .to_string(),
372        );
373    }
374
375    None
376}
377
378fn build_artifacts_excerpt_v1(project_root: &Path) -> ArtifactsExcerptV1 {
379    let mut out = ArtifactsExcerptV1::default();
380
381    let resolved = crate::core::artifacts::load_resolved(project_root);
382    out.warnings.extend(resolved.warnings);
383    out.resolved = resolved
384        .artifacts
385        .into_iter()
386        .take(MAX_ARTIFACT_ITEMS)
387        .collect();
388
389    let proofs_dir = match crate::core::pathutil::safe_project_data_dir(project_root) {
390        Ok(d) => d.join("proofs"),
391        Err(_) => return out,
392    };
393    if let Ok(rd) = std::fs::read_dir(&proofs_dir) {
394        let mut files = Vec::new();
395        for e in rd.flatten() {
396            let p = e.path();
397            if !p.is_file() {
398                continue;
399            }
400            let name = p
401                .file_name()
402                .map(|n| n.to_string_lossy().to_string())
403                .unwrap_or_default();
404            if name.is_empty() {
405                continue;
406            }
407            let bytes = p.metadata().map_or(0, |m| m.len());
408            let md5 = match std::fs::read(&p) {
409                Ok(b) => crate::core::hasher::hash_hex(&b),
410                Err(e) => {
411                    out.warnings
412                        .push(format!("proof read failed: {} ({e})", p.display()));
413                    continue;
414                }
415            };
416            files.push(ProofFileV1 { name, md5, bytes });
417        }
418        files.sort_by(|a, b| a.name.cmp(&b.name));
419        out.proof_files = files.into_iter().take(MAX_PROOF_FILES).collect();
420    }
421
422    out
423}
424
425fn cap_ledger_in_place(ledger: &mut HandoffLedgerV1) {
426    if ledger.session_snapshot.len() > MAX_LEDGER_SNAPSHOT_CHARS {
427        ledger.session_snapshot =
428            truncate_chars(&ledger.session_snapshot, MAX_LEDGER_SNAPSHOT_CHARS);
429    }
430
431    if let Some(ref mut task) = ledger.session.task {
432        *task = truncate_chars(task, MAX_TASK_CHARS);
433    }
434
435    for d in &mut ledger.session.decisions {
436        *d = truncate_chars(d, MAX_DECISION_CHARS);
437    }
438    for f in &mut ledger.session.findings {
439        *f = truncate_chars(f, MAX_FINDING_CHARS);
440    }
441    for s in &mut ledger.session.next_steps {
442        *s = truncate_chars(s, MAX_NEXT_STEP_CHARS);
443    }
444
445    for r in &mut ledger.curated_refs {
446        if r.content.len() > MAX_CURATED_REF_CHARS {
447            r.content = truncate_chars(&r.content, MAX_CURATED_REF_CHARS);
448        }
449    }
450}
451
452fn redact_ledger_in_place(ledger: &mut HandoffLedgerV1) {
453    ledger.project_root = None;
454    ledger.session_snapshot.clear();
455
456    if let Some(ref mut task) = ledger.session.task {
457        *task = crate::core::redaction::redact_text(task);
458    }
459    for d in &mut ledger.session.decisions {
460        *d = crate::core::redaction::redact_text(d);
461    }
462    for f in &mut ledger.session.findings {
463        *f = crate::core::redaction::redact_text(f);
464    }
465    for s in &mut ledger.session.next_steps {
466        *s = crate::core::redaction::redact_text(s);
467    }
468
469    for fact in &mut ledger.knowledge.facts {
470        fact.value = crate::core::redaction::redact_text(&fact.value);
471    }
472
473    for r in &mut ledger.curated_refs {
474        r.content = crate::core::redaction::redact_text(&r.content);
475    }
476}
477
478fn truncate_chars(s: &str, max: usize) -> String {
479    if s.chars().count() <= max {
480        return s.to_string();
481    }
482    s.chars().take(max).collect::<String>()
483}
484
485#[cfg(test)]
486mod tests {
487    use super::*;
488
489    fn sample_ledger() -> HandoffLedgerV1 {
490        HandoffLedgerV1 {
491            schema_version: crate::core::contracts::HANDOFF_LEDGER_V1_SCHEMA_VERSION,
492            created_at: "20260503T000000Z".to_string(),
493            content_md5: "old".to_string(),
494            manifest_md5: "m".to_string(),
495            project_root: Some("/abs/project".to_string()),
496            agent_id: Some("a".to_string()),
497            client_name: Some("cursor".to_string()),
498            workflow: None,
499            session_snapshot: "snapshot".to_string(),
500            session: crate::core::handoff_ledger::SessionExcerpt {
501                id: "s".to_string(),
502                task: Some("task".to_string()),
503                decisions: vec!["d1".to_string()],
504                findings: vec!["f1".to_string()],
505                next_steps: vec!["n1".to_string()],
506            },
507            tool_calls: crate::core::handoff_ledger::ToolCallsSummary::default(),
508            evidence_keys: vec!["tool:ctx_read".to_string()],
509            knowledge: crate::core::handoff_ledger::KnowledgeExcerpt {
510                project_hash: None,
511                facts: vec![crate::core::handoff_ledger::KnowledgeFactMini {
512                    category: "c".to_string(),
513                    key: "k".to_string(),
514                    value: "secret=abcdef0123456789abcdef0123456789".to_string(),
515                    confidence: 0.9,
516                }],
517            },
518            curated_refs: vec![crate::core::handoff_ledger::CuratedRef {
519                path: "src/lib.rs".to_string(),
520                mode: "signatures".to_string(),
521                content_md5: "x".to_string(),
522                content: "fn a() {}".to_string(),
523            }],
524            active_overlays: Vec::new(),
525            merged_from: Vec::new(),
526            request_id: None,
527            request_digest: None,
528        }
529    }
530
531    #[test]
532    fn redacted_bundle_removes_sensitive_fields() {
533        let ledger = sample_ledger();
534        let b = build_bundle_v1(ledger, None, BundlePrivacyV1::Redacted);
535        assert_eq!(b.privacy, "redacted");
536        assert!(b.ledger.project_root.is_none());
537        assert!(
538            b.ledger.session_snapshot.is_empty(),
539            "{:?}",
540            b.ledger.session_snapshot
541        );
542    }
543
544    #[test]
545    fn serialize_parse_roundtrip() {
546        let ledger = sample_ledger();
547        let b = build_bundle_v1(ledger, None, BundlePrivacyV1::Redacted);
548        let json = serialize_bundle_v1_pretty(&b).expect("json");
549        assert!(json.len() < MAX_BUNDLE_BYTES);
550        let parsed = parse_bundle_v1(&json).expect("parse");
551        assert_eq!(parsed.schema_version, b.schema_version);
552        assert_eq!(parsed.privacy, "redacted");
553    }
554
555    #[test]
556    fn unicode_signature_material_is_invalid() {
557        for malformed in ["\u{1f512}", "0\u{20ac}", "\u{e9}\u{e9}"] {
558            for signature_is_malformed in [true, false] {
559                let mut bundle = build_bundle_v1(sample_ledger(), None, BundlePrivacyV1::Redacted);
560                bundle.signer_agent_id = Some("unicode-regression".to_string());
561                bundle.signature = Some(if signature_is_malformed {
562                    malformed.to_string()
563                } else {
564                    "00".repeat(64)
565                });
566                bundle.signer_public_key = Some(if signature_is_malformed {
567                    "00".repeat(32)
568                } else {
569                    malformed.to_string()
570                });
571                let serialized = serialize_bundle_v1_pretty(&bundle).unwrap();
572                let parsed = parse_bundle_v1(&serialized).unwrap();
573                assert_eq!(
574                    check_bundle_signature(&parsed),
575                    BundleSignatureStatus::Invalid("non-ASCII hex string".to_string())
576                );
577            }
578        }
579    }
580
581    /// GL #465: signed bundles verify; any tampering after signing flips the
582    /// status to `Invalid` (fail-closed); bundles without signature fields are
583    /// `Unsigned` (legacy, allowed with warning).
584    #[test]
585    fn import_signature_check_verified_unsigned_invalid() {
586        // Signing writes the agent key under the data dir. Own it for the
587        // test: a concurrent test that points LEAN_CTX_DATA_DIR at a temp dir
588        // and deletes it made `sign` fail with "path not found" (Windows CI).
589        let _lock = crate::core::data_dir::test_env_lock();
590        let data_dir = tempfile::tempdir().unwrap();
591        crate::test_env::set_var("LEAN_CTX_DATA_DIR", data_dir.path());
592
593        let unsigned = build_bundle_v1(sample_ledger(), None, BundlePrivacyV1::Redacted);
594        assert_eq!(
595            check_bundle_signature(&unsigned),
596            BundleSignatureStatus::Unsigned
597        );
598
599        let mut signed = build_bundle_v1(sample_ledger(), None, BundlePrivacyV1::Redacted);
600        sign_bundle(&mut signed, "handoff-sig-test-agent").expect("sign");
601        // Signed by a key held by a local agent identity → trusted anchor.
602        match check_bundle_signature(&signed) {
603            BundleSignatureStatus::Signed(v) => {
604                assert_eq!(v.claimed_signer, "handoff-sig-test-agent");
605                assert!(v.signer_bound && v.trusted, "{v:?}");
606                assert_eq!(v.status(), "verified");
607            }
608            other => panic!("expected Signed, got {other:?}"),
609        }
610
611        // GHSA-gvg8-285q-m62g: relabelling the signer breaks the signature.
612        let mut relabelled = signed.clone();
613        relabelled.signer_agent_id = Some("trusted-release-bot".to_string());
614        assert!(matches!(
615            check_bundle_signature(&relabelled),
616            BundleSignatureStatus::Invalid(_)
617        ));
618        // Stripping the scheme (downgrade to legacy) does not verify either.
619        let mut downgraded = signed.clone();
620        downgraded.signature_scheme = None;
621        assert!(matches!(
622            check_bundle_signature(&downgraded),
623            BundleSignatureStatus::Invalid(_)
624        ));
625
626        // Tamper with the payload after signing → must be Invalid, not Unsigned.
627        let mut tampered = signed.clone();
628        tampered.ledger.session.task = Some("tampered task".to_string());
629        assert!(matches!(
630            check_bundle_signature(&tampered),
631            BundleSignatureStatus::Invalid(_)
632        ));
633
634        // Partial signature material (e.g. stripped pubkey) is Invalid too.
635        let mut partial = signed;
636        partial.signer_public_key = None;
637        assert!(matches!(
638            check_bundle_signature(&partial),
639            BundleSignatureStatus::Invalid(_)
640        ));
641
642        crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
643    }
644
645    /// GHSA-gvg8-285q-m62g: a self-embedded key is never reported as a
646    /// verified identity, and legacy signatures surface as unbound.
647    #[test]
648    fn foreign_and_legacy_signatures_are_not_verified_identities() {
649        let _lock = crate::core::data_dir::test_env_lock();
650        let data_dir = tempfile::tempdir().unwrap();
651        crate::test_env::set_var("LEAN_CTX_DATA_DIR", data_dir.path());
652        let key = ed25519_dalek::SigningKey::from_bytes(&[7u8; 32]);
653        let pk_hex = crate::core::agent_identity::hex_encode(&key.verifying_key().to_bytes());
654
655        // Bound signature from a key no anchor knows.
656        let mut bound = build_bundle_v1(sample_ledger(), None, BundlePrivacyV1::Redacted);
657        bound.signer_public_key = Some(pk_hex.clone());
658        bound.signer_agent_id = Some("trusted-release-bot".to_string());
659        bound.signature_scheme = Some(BOUND_SIGNATURE_SCHEME.to_string());
660        let canonical = serde_json::to_string(&bound).unwrap();
661        bound.signature = Some(crate::core::agent_identity::hex_encode(
662            &crate::core::agent_identity::sign_bytes_with(&key, canonical.as_bytes()),
663        ));
664        let BundleSignatureStatus::Signed(v) = check_bundle_signature(&bound) else {
665            panic!("bound signature must verify");
666        };
667        assert!(v.signer_bound && !v.trusted);
668        assert_eq!(v.status(), "untrusted_signer");
669        assert!(v.describe().contains("(untrusted"), "{}", v.describe());
670        assert!(!v.describe().starts_with("verified"));
671
672        // Pinning (subject, key) in the trust store anchors it.
673        crate::core::signer_trust::pin_if_absent("trusted-release-bot", &pk_hex, "test").unwrap();
674        let BundleSignatureStatus::Signed(v) = check_bundle_signature(&bound) else {
675            panic!("bound signature must verify");
676        };
677        assert!(v.trusted);
678
679        // Legacy signature (signer fields outside the payload) still verifies,
680        // but is reported unbound and never trusted — even for a pinned key.
681        let mut legacy = build_bundle_v1(sample_ledger(), None, BundlePrivacyV1::Redacted);
682        let canonical = serde_json::to_string(&legacy).unwrap();
683        legacy.signature = Some(crate::core::agent_identity::hex_encode(
684            &crate::core::agent_identity::sign_bytes_with(&key, canonical.as_bytes()),
685        ));
686        legacy.signer_public_key = Some(pk_hex);
687        legacy.signer_agent_id = Some("trusted-release-bot".to_string());
688        let BundleSignatureStatus::Signed(v) = check_bundle_signature(&legacy) else {
689            panic!("legacy signature must still verify");
690        };
691        assert!(!v.signer_bound && !v.trusted);
692        assert_eq!(v.status(), "legacy_unbound");
693        assert!(v.describe().contains("legacy signature"));
694
695        crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
696    }
697}