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 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 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 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#[derive(Debug, Clone, PartialEq, Eq)]
162pub enum BundleSignatureStatus {
163 Verified(String),
165 Unsigned,
167 Invalid(String),
169}
170
171#[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 != ¤t_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 #[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 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 let mut partial = signed;
505 partial.signer_public_key = None;
506 assert!(matches!(
507 check_bundle_signature(&partial),
508 BundleSignatureStatus::Invalid(_)
509 ));
510 }
511}