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 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}
54
55#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct ProjectIdentityV1 {
57    pub project_root_hash: Option<String>,
58    pub project_identity_hash: Option<String>,
59}
60
61#[derive(Debug, Clone, Serialize, Deserialize, Default)]
62pub struct ArtifactsExcerptV1 {
63    pub resolved: Vec<crate::core::artifacts::ResolvedArtifact>,
64    pub proof_files: Vec<ProofFileV1>,
65    pub warnings: Vec<String>,
66}
67
68#[derive(Debug, Clone, Serialize, Deserialize)]
69pub struct ProofFileV1 {
70    pub name: String,
71    pub md5: String,
72    pub bytes: u64,
73}
74
75pub fn build_bundle_v1(
76    mut ledger: HandoffLedgerV1,
77    project_root: Option<&str>,
78    privacy: BundlePrivacyV1,
79) -> HandoffTransferBundleV1 {
80    let role_name = crate::core::roles::active_role_name();
81    let effective_privacy = match privacy {
82        BundlePrivacyV1::Full
83            if role_name == "admin"
84                && !crate::core::redaction::redaction_enabled_for_active_role() =>
85        {
86            BundlePrivacyV1::Full
87        }
88        _ => BundlePrivacyV1::Redacted,
89    };
90
91    let (project_root_hash, project_identity_hash) = project_root.map_or((None, None), |root| {
92        let root_hash = crate::core::project_hash::hash_project_root(root);
93        let identity = crate::core::project_hash::project_identity(root);
94        let identity_hash = identity.as_deref().map(crate::core::hasher::hash_str);
95        (Some(root_hash), identity_hash)
96    });
97
98    cap_ledger_in_place(&mut ledger);
99
100    match effective_privacy {
101        BundlePrivacyV1::Full => {}
102        BundlePrivacyV1::Redacted => {
103            redact_ledger_in_place(&mut ledger);
104        }
105    }
106
107    // Keep embedded ledger internally consistent.
108    ledger.content_md5 = crate::core::handoff_ledger::compute_content_md5_for_ledger(&ledger);
109
110    let artifacts = project_root
111        .map(Path::new)
112        .map(build_artifacts_excerpt_v1)
113        .unwrap_or_default();
114
115    // Built unsigned by design: signing is the exporter's responsibility
116    // (`ctx_handoff export` signs with the real agent identity, GL #465).
117    // The old implicit role-name signing here conflated role with identity
118    // and silently swallowed failures.
119    HandoffTransferBundleV1 {
120        schema_version: crate::core::contracts::HANDOFF_TRANSFER_BUNDLE_V1_SCHEMA_VERSION,
121        exported_at: Utc::now(),
122        privacy: effective_privacy.as_str().to_string(),
123        project: ProjectIdentityV1 {
124            project_root_hash,
125            project_identity_hash,
126        },
127        ledger,
128        artifacts,
129        signature: None,
130        signer_public_key: None,
131        signer_agent_id: None,
132    }
133}
134
135pub fn sign_bundle(bundle: &mut HandoffTransferBundleV1, agent_id: &str) -> Result<(), String> {
136    bundle.signature = None;
137    bundle.signer_public_key = None;
138    bundle.signer_agent_id = None;
139
140    let canonical =
141        serde_json::to_string(bundle).map_err(|e| format!("serialize for signing: {e}"))?;
142
143    // One atomic key resolution — signature and embedded public key must come
144    // from the same keypair (two separate lookups raced with concurrent
145    // data-dir changes and produced unverifiable bundles).
146    let (sig_bytes, pub_key) =
147        crate::core::agent_identity::sign_with_public_key(agent_id, canonical.as_bytes())?;
148
149    bundle.signature = Some(crate::core::agent_identity::hex_encode(&sig_bytes));
150    bundle.signer_public_key = Some(crate::core::agent_identity::hex_encode(&pub_key.to_bytes()));
151    bundle.signer_agent_id = Some(agent_id.to_string());
152    Ok(())
153}
154
155/// Outcome of checking a bundle's embedded Ed25519 signature on import
156/// (GL #465). Fail-closed semantics: any *present-but-broken* signature
157/// material is [`Invalid`](BundleSignatureStatus::Invalid) and must block the
158/// import; only the complete absence of all three signature fields counts as
159/// a legacy [`Unsigned`](BundleSignatureStatus::Unsigned) bundle (allowed
160/// with a warning, for bundles produced before exports were signed).
161#[derive(Debug, Clone, PartialEq, Eq)]
162pub enum BundleSignatureStatus {
163    /// Valid signature; carries the verified signer agent id.
164    Verified(String),
165    /// No signature fields at all (legacy bundle).
166    Unsigned,
167    /// Signature material present but not verifiable — reject the bundle.
168    Invalid(String),
169}
170
171/// Classify a bundle's signature for import-time enforcement (GL #465).
172#[must_use]
173pub fn check_bundle_signature(bundle: &HandoffTransferBundleV1) -> BundleSignatureStatus {
174    if bundle.signature.is_none()
175        && bundle.signer_public_key.is_none()
176        && bundle.signer_agent_id.is_none()
177    {
178        return BundleSignatureStatus::Unsigned;
179    }
180    match verify_bundle_signature(bundle) {
181        Ok(signer) => BundleSignatureStatus::Verified(signer),
182        Err(e) => BundleSignatureStatus::Invalid(e),
183    }
184}
185
186pub fn verify_bundle_signature(bundle: &HandoffTransferBundleV1) -> Result<String, String> {
187    let sig_hex = bundle
188        .signature
189        .as_deref()
190        .ok_or_else(|| "bundle has no signature".to_string())?;
191    let pk_hex = bundle
192        .signer_public_key
193        .as_deref()
194        .ok_or_else(|| "bundle has no signer_public_key".to_string())?;
195    let agent_id = bundle
196        .signer_agent_id
197        .as_deref()
198        .ok_or_else(|| "bundle has no signer_agent_id".to_string())?;
199
200    let sig_bytes = crate::core::agent_identity::hex_decode(sig_hex)?;
201    let pk_bytes = crate::core::agent_identity::hex_decode(pk_hex)?;
202
203    let mut verify_bundle = bundle.clone();
204    verify_bundle.signature = None;
205    verify_bundle.signer_public_key = None;
206    verify_bundle.signer_agent_id = None;
207
208    let canonical =
209        serde_json::to_string(&verify_bundle).map_err(|e| format!("serialize for verify: {e}"))?;
210
211    if crate::core::agent_identity::verify_signature(&pk_bytes, canonical.as_bytes(), &sig_bytes) {
212        Ok(agent_id.to_string())
213    } else {
214        Err("signature verification failed".to_string())
215    }
216}
217
218pub fn serialize_bundle_v1_pretty(bundle: &HandoffTransferBundleV1) -> Result<String, String> {
219    let json = serde_json::to_string_pretty(bundle).map_err(|e| e.to_string())?;
220    if json.len() > MAX_BUNDLE_BYTES {
221        return Err(format!(
222            "ERROR: bundle too large ({} bytes > max {}). Use privacy=redacted and/or reduce curated refs.",
223            json.len(),
224            MAX_BUNDLE_BYTES
225        ));
226    }
227    Ok(json)
228}
229
230pub fn parse_bundle_v1(json: &str) -> Result<HandoffTransferBundleV1, String> {
231    let b: HandoffTransferBundleV1 = serde_json::from_str(json).map_err(|e| e.to_string())?;
232    if b.schema_version != crate::core::contracts::HANDOFF_TRANSFER_BUNDLE_V1_SCHEMA_VERSION {
233        return Err(format!(
234            "ERROR: unsupported schema_version {} (expected {})",
235            b.schema_version,
236            crate::core::contracts::HANDOFF_TRANSFER_BUNDLE_V1_SCHEMA_VERSION
237        ));
238    }
239    Ok(b)
240}
241
242pub fn write_bundle_v1(path: &Path, json: &str) -> Result<(), String> {
243    let parent = path
244        .parent()
245        .ok_or_else(|| "ERROR: invalid path".to_string())?;
246    if !parent.exists() {
247        std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
248    }
249    let tmp = parent.join(format!(
250        ".{}.tmp",
251        path.file_name()
252            .and_then(|s| s.to_str())
253            .unwrap_or("bundle")
254    ));
255    std::fs::write(&tmp, json).map_err(|e| e.to_string())?;
256    std::fs::rename(&tmp, path).map_err(|e| e.to_string())?;
257    Ok(())
258}
259
260pub fn read_bundle_v1(path: &Path) -> Result<HandoffTransferBundleV1, String> {
261    let json = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
262    if json.len() > MAX_BUNDLE_BYTES {
263        return Err(format!(
264            "ERROR: bundle file too large ({} bytes > max {})",
265            json.len(),
266            MAX_BUNDLE_BYTES
267        ));
268    }
269    parse_bundle_v1(&json)
270}
271
272pub fn project_identity_warning(
273    bundle: &HandoffTransferBundleV1,
274    project_root: &str,
275) -> Option<String> {
276    let current_root_hash = crate::core::project_hash::hash_project_root(project_root);
277    let current_identity_hash = crate::core::project_hash::project_identity(project_root)
278        .as_deref()
279        .map(crate::core::hasher::hash_str);
280
281    if let Some(ref exported) = bundle.project.project_root_hash {
282        if exported != &current_root_hash {
283            return Some(
284                "WARNING: project_root_hash mismatch (importing into different project root)."
285                    .to_string(),
286            );
287        }
288    }
289
290    if let (Some(exported), Some(current)) = (
291        bundle.project.project_identity_hash.as_ref(),
292        current_identity_hash.as_ref(),
293    ) {
294        if exported != current {
295            return Some(
296                "WARNING: project_identity_hash mismatch (importing into different project identity)."
297                    .to_string(),
298            );
299        }
300    }
301
302    None
303}
304
305fn build_artifacts_excerpt_v1(project_root: &Path) -> ArtifactsExcerptV1 {
306    let mut out = ArtifactsExcerptV1::default();
307
308    let resolved = crate::core::artifacts::load_resolved(project_root);
309    out.warnings.extend(resolved.warnings);
310    out.resolved = resolved
311        .artifacts
312        .into_iter()
313        .take(MAX_ARTIFACT_ITEMS)
314        .collect();
315
316    let proofs_dir = match crate::core::pathutil::safe_project_data_dir(project_root) {
317        Ok(d) => d.join("proofs"),
318        Err(_) => return out,
319    };
320    if let Ok(rd) = std::fs::read_dir(&proofs_dir) {
321        let mut files = Vec::new();
322        for e in rd.flatten() {
323            let p = e.path();
324            if !p.is_file() {
325                continue;
326            }
327            let name = p
328                .file_name()
329                .map(|n| n.to_string_lossy().to_string())
330                .unwrap_or_default();
331            if name.is_empty() {
332                continue;
333            }
334            let bytes = p.metadata().map_or(0, |m| m.len());
335            let md5 = match std::fs::read(&p) {
336                Ok(b) => crate::core::hasher::hash_hex(&b),
337                Err(e) => {
338                    out.warnings
339                        .push(format!("proof read failed: {} ({e})", p.display()));
340                    continue;
341                }
342            };
343            files.push(ProofFileV1 { name, md5, bytes });
344        }
345        files.sort_by(|a, b| a.name.cmp(&b.name));
346        out.proof_files = files.into_iter().take(MAX_PROOF_FILES).collect();
347    }
348
349    out
350}
351
352fn cap_ledger_in_place(ledger: &mut HandoffLedgerV1) {
353    if ledger.session_snapshot.len() > MAX_LEDGER_SNAPSHOT_CHARS {
354        ledger.session_snapshot =
355            truncate_chars(&ledger.session_snapshot, MAX_LEDGER_SNAPSHOT_CHARS);
356    }
357
358    if let Some(ref mut task) = ledger.session.task {
359        *task = truncate_chars(task, MAX_TASK_CHARS);
360    }
361
362    for d in &mut ledger.session.decisions {
363        *d = truncate_chars(d, MAX_DECISION_CHARS);
364    }
365    for f in &mut ledger.session.findings {
366        *f = truncate_chars(f, MAX_FINDING_CHARS);
367    }
368    for s in &mut ledger.session.next_steps {
369        *s = truncate_chars(s, MAX_NEXT_STEP_CHARS);
370    }
371
372    for r in &mut ledger.curated_refs {
373        if r.content.len() > MAX_CURATED_REF_CHARS {
374            r.content = truncate_chars(&r.content, MAX_CURATED_REF_CHARS);
375        }
376    }
377}
378
379fn redact_ledger_in_place(ledger: &mut HandoffLedgerV1) {
380    ledger.project_root = None;
381    ledger.session_snapshot.clear();
382
383    if let Some(ref mut task) = ledger.session.task {
384        *task = crate::core::redaction::redact_text(task);
385    }
386    for d in &mut ledger.session.decisions {
387        *d = crate::core::redaction::redact_text(d);
388    }
389    for f in &mut ledger.session.findings {
390        *f = crate::core::redaction::redact_text(f);
391    }
392    for s in &mut ledger.session.next_steps {
393        *s = crate::core::redaction::redact_text(s);
394    }
395
396    for fact in &mut ledger.knowledge.facts {
397        fact.value = crate::core::redaction::redact_text(&fact.value);
398    }
399
400    for r in &mut ledger.curated_refs {
401        r.content = crate::core::redaction::redact_text(&r.content);
402    }
403}
404
405fn truncate_chars(s: &str, max: usize) -> String {
406    if s.chars().count() <= max {
407        return s.to_string();
408    }
409    s.chars().take(max).collect::<String>()
410}
411
412#[cfg(test)]
413mod tests {
414    use super::*;
415
416    fn sample_ledger() -> HandoffLedgerV1 {
417        HandoffLedgerV1 {
418            schema_version: crate::core::contracts::HANDOFF_LEDGER_V1_SCHEMA_VERSION,
419            created_at: "20260503T000000Z".to_string(),
420            content_md5: "old".to_string(),
421            manifest_md5: "m".to_string(),
422            project_root: Some("/abs/project".to_string()),
423            agent_id: Some("a".to_string()),
424            client_name: Some("cursor".to_string()),
425            workflow: None,
426            session_snapshot: "snapshot".to_string(),
427            session: crate::core::handoff_ledger::SessionExcerpt {
428                id: "s".to_string(),
429                task: Some("task".to_string()),
430                decisions: vec!["d1".to_string()],
431                findings: vec!["f1".to_string()],
432                next_steps: vec!["n1".to_string()],
433            },
434            tool_calls: crate::core::handoff_ledger::ToolCallsSummary::default(),
435            evidence_keys: vec!["tool:ctx_read".to_string()],
436            knowledge: crate::core::handoff_ledger::KnowledgeExcerpt {
437                project_hash: None,
438                facts: vec![crate::core::handoff_ledger::KnowledgeFactMini {
439                    category: "c".to_string(),
440                    key: "k".to_string(),
441                    value: "secret=abcdef0123456789abcdef0123456789".to_string(),
442                    confidence: 0.9,
443                }],
444            },
445            curated_refs: vec![crate::core::handoff_ledger::CuratedRef {
446                path: "src/lib.rs".to_string(),
447                mode: "signatures".to_string(),
448                content_md5: "x".to_string(),
449                content: "fn a() {}".to_string(),
450            }],
451            active_overlays: Vec::new(),
452        }
453    }
454
455    #[test]
456    fn redacted_bundle_removes_sensitive_fields() {
457        let ledger = sample_ledger();
458        let b = build_bundle_v1(ledger, None, BundlePrivacyV1::Redacted);
459        assert_eq!(b.privacy, "redacted");
460        assert!(b.ledger.project_root.is_none());
461        assert!(b.ledger.session_snapshot.is_empty());
462    }
463
464    #[test]
465    fn serialize_parse_roundtrip() {
466        let ledger = sample_ledger();
467        let b = build_bundle_v1(ledger, None, BundlePrivacyV1::Redacted);
468        let json = serialize_bundle_v1_pretty(&b).expect("json");
469        assert!(json.len() < MAX_BUNDLE_BYTES);
470        let parsed = parse_bundle_v1(&json).expect("parse");
471        assert_eq!(parsed.schema_version, b.schema_version);
472        assert_eq!(parsed.privacy, "redacted");
473    }
474
475    /// GL #465: signed bundles verify; any tampering after signing flips the
476    /// status to `Invalid` (fail-closed); bundles without signature fields are
477    /// `Unsigned` (legacy, allowed with warning).
478    #[test]
479    fn import_signature_check_verified_unsigned_invalid() {
480        let unsigned = build_bundle_v1(sample_ledger(), None, BundlePrivacyV1::Redacted);
481        assert_eq!(
482            check_bundle_signature(&unsigned),
483            BundleSignatureStatus::Unsigned
484        );
485
486        let mut signed = build_bundle_v1(sample_ledger(), None, BundlePrivacyV1::Redacted);
487        sign_bundle(&mut signed, "handoff-sig-test-agent").expect("sign");
488        match check_bundle_signature(&signed) {
489            BundleSignatureStatus::Verified(signer) => {
490                assert_eq!(signer, "handoff-sig-test-agent");
491            }
492            other => panic!("expected Verified, got {other:?}"),
493        }
494
495        // Tamper with the payload after signing → must be Invalid, not Unsigned.
496        let mut tampered = signed.clone();
497        tampered.ledger.session.task = Some("tampered task".to_string());
498        assert!(matches!(
499            check_bundle_signature(&tampered),
500            BundleSignatureStatus::Invalid(_)
501        ));
502
503        // Partial signature material (e.g. stripped pubkey) is Invalid too.
504        let mut partial = signed;
505        partial.signer_public_key = None;
506        assert!(matches!(
507            check_bundle_signature(&partial),
508            BundleSignatureStatus::Invalid(_)
509        ));
510    }
511}