1use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5use std::path::{Path, PathBuf};
6
7use crate::core::session::{
8 Decision, EvidenceRecord, FileTouched, Finding, ProgressEntry, SessionState, SessionStats,
9 TaskInfo, TestSnapshot,
10};
11
12const MAX_BUNDLE_BYTES: usize = 250_000;
13const MAX_NEXT_STEPS: usize = 25;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum BundlePrivacyV1 {
17 Redacted,
18 Full,
19}
20
21impl BundlePrivacyV1 {
22 pub fn parse(s: Option<&str>) -> Self {
23 match s.unwrap_or("redacted").trim().to_lowercase().as_str() {
24 "full" => Self::Full,
25 _ => Self::Redacted,
26 }
27 }
28}
29
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct CcpSessionBundleV1 {
32 pub schema_version: u32,
33 pub exported_at: DateTime<Utc>,
34 pub project: ProjectIdentityV1,
35 pub role: PolicyIdentityV1,
36 pub profile: PolicyIdentityV1,
37 pub session: SessionExcerptV1,
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct ProjectIdentityV1 {
42 pub project_root_hash: Option<String>,
43 pub project_identity_hash: Option<String>,
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct PolicyIdentityV1 {
48 pub name: String,
49 #[serde(rename = "policy_md5")]
51 pub policy_blake3: String,
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct SessionExcerptV1 {
56 pub id: String,
57 pub version: u32,
58 pub started_at: DateTime<Utc>,
59 pub updated_at: DateTime<Utc>,
60 pub project_root: Option<String>,
61 pub shell_cwd: Option<String>,
62 pub task: Option<TaskInfo>,
63 pub findings: Vec<Finding>,
64 pub decisions: Vec<Decision>,
65 pub files_touched: Vec<FileTouched>,
66 pub test_results: Option<TestSnapshot>,
67 pub progress: Vec<ProgressEntry>,
68 pub next_steps: Vec<String>,
69 pub evidence: Vec<EvidenceRecord>,
70 pub stats: SessionStats,
71 #[serde(default)]
72 pub terse_mode: bool,
73 #[serde(default)]
74 pub compression_level: String,
75}
76
77pub fn build_bundle_v1(session: &SessionState, privacy: BundlePrivacyV1) -> CcpSessionBundleV1 {
78 let role_name = crate::core::roles::active_role_name();
79 let role = crate::core::roles::active_role();
80 let profile_name = crate::core::profiles::active_profile_name();
81 let profile = crate::core::profiles::active_profile();
82
83 let role_policy_blake3 =
84 crate::core::hasher::hash_str(&serde_json::to_string(&role).unwrap_or_default());
85 let profile_policy_blake3 =
86 crate::core::hasher::hash_str(&serde_json::to_string(&profile).unwrap_or_default());
87
88 let (project_root_hash, project_identity_hash) =
89 session
90 .project_root
91 .as_deref()
92 .map_or((None, None), |root| {
93 let root_hash = crate::core::project_hash::hash_project_root(root);
94 let identity = crate::core::project_hash::project_identity(root);
95 let identity_hash = identity.as_deref().map(crate::core::hasher::hash_str);
96 (Some(root_hash), identity_hash)
97 });
98
99 let mut excerpt = SessionExcerptV1 {
100 id: session.id.clone(),
101 version: session.version,
102 started_at: session.started_at,
103 updated_at: session.updated_at,
104 project_root: session.project_root.clone(),
105 shell_cwd: session.shell_cwd.clone(),
106 task: session.task.clone(),
107 findings: session
111 .findings
112 .iter()
113 .filter(|finding| session.finding_served(finding))
114 .cloned()
115 .collect(),
116 decisions: session.decisions.clone(),
117 files_touched: session.files_touched.clone(),
118 test_results: session.test_results.clone(),
119 progress: session.progress.clone(),
120 next_steps: session
121 .next_steps
122 .iter()
123 .take(MAX_NEXT_STEPS)
124 .cloned()
125 .collect(),
126 evidence: session.evidence.clone(),
127 stats: session.stats.clone(),
128 terse_mode: session.terse_mode,
129 compression_level: session.compression_level.clone(),
130 };
131
132 let root = excerpt.project_root.clone().unwrap_or_default();
134 if !root.is_empty() {
135 for f in &mut excerpt.files_touched {
136 if let Some(rel) = strip_root_prefix(&root, &f.path) {
137 f.path = rel;
138 }
139 }
140 for finding in &mut excerpt.findings {
141 if let Some(ref file) = finding.file.clone()
142 && let Some(rel) = strip_root_prefix(&root, file)
143 {
144 finding.file = Some(rel);
145 }
146 }
147 }
148
149 match privacy {
150 BundlePrivacyV1::Full => {
151 if role_name != "admin" {
153 redact_excerpt_in_place(&mut excerpt);
154 } else if crate::core::redaction::redaction_enabled_for_active_role() {
155 redact_excerpt_in_place(&mut excerpt);
157 }
158 }
159 BundlePrivacyV1::Redacted => {
160 redact_excerpt_in_place(&mut excerpt);
161 }
162 }
163
164 CcpSessionBundleV1 {
165 schema_version: crate::core::contracts::CCP_SESSION_BUNDLE_V1_SCHEMA_VERSION,
166 exported_at: Utc::now(),
167 project: ProjectIdentityV1 {
168 project_root_hash,
169 project_identity_hash,
170 },
171 role: PolicyIdentityV1 {
172 name: role_name,
173 policy_blake3: role_policy_blake3,
174 },
175 profile: PolicyIdentityV1 {
176 name: profile_name,
177 policy_blake3: profile_policy_blake3,
178 },
179 session: excerpt,
180 }
181}
182
183pub fn serialize_bundle_v1_pretty(bundle: &CcpSessionBundleV1) -> Result<String, String> {
184 let json = serde_json::to_string_pretty(bundle).map_err(|e| e.to_string())?;
185 if json.len() > MAX_BUNDLE_BYTES {
186 return Err(format!(
187 "ERROR: bundle too large ({} bytes > max {}). Use privacy=redacted and/or reduce session evidence.",
188 json.len(),
189 MAX_BUNDLE_BYTES
190 ));
191 }
192 Ok(json)
193}
194
195pub(crate) fn parse_bundle_v1(json: &str) -> Result<CcpSessionBundleV1, String> {
196 let b: CcpSessionBundleV1 = serde_json::from_str(json).map_err(|e| e.to_string())?;
197 if b.schema_version != crate::core::contracts::CCP_SESSION_BUNDLE_V1_SCHEMA_VERSION {
198 return Err(format!(
199 "ERROR: unsupported schema_version {} (expected {})",
200 b.schema_version,
201 crate::core::contracts::CCP_SESSION_BUNDLE_V1_SCHEMA_VERSION
202 ));
203 }
204 Ok(b)
205}
206
207pub fn write_bundle_v1(path: &Path, json: &str) -> Result<(), String> {
208 let parent = path
209 .parent()
210 .ok_or_else(|| "ERROR: invalid path".to_string())?;
211 if !parent.exists() {
212 std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
213 }
214 let tmp = parent.join(format!(
215 ".{}.tmp",
216 path.file_name()
217 .and_then(|s| s.to_str())
218 .unwrap_or("bundle")
219 ));
220 std::fs::write(&tmp, json).map_err(|e| e.to_string())?;
221 std::fs::rename(&tmp, path).map_err(|e| e.to_string())?;
222 Ok(())
223}
224
225pub fn read_bundle_v1(path: &Path) -> Result<CcpSessionBundleV1, String> {
226 use std::io::Read as _;
227 let mut options = std::fs::OpenOptions::new();
228 options.read(true);
229 #[cfg(unix)]
230 {
231 use std::os::unix::fs::OpenOptionsExt as _;
232 options.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK);
233 }
234 #[cfg(windows)]
235 {
236 use std::os::windows::fs::OpenOptionsExt as _;
237 options.custom_flags(0x0020_0000); }
239 let file = options.open(path).map_err(|_| "bundle cannot be opened")?;
240 let metadata = file.metadata().map_err(|_| "bundle metadata unavailable")?;
241 #[cfg(windows)]
242 {
243 use std::os::windows::fs::MetadataExt as _;
244 if metadata.file_attributes() & 0x0400 != 0 {
245 return Err("bundle reparse point rejected".into());
246 }
247 }
248 if !metadata.is_file() || metadata.len() > MAX_BUNDLE_BYTES as u64 {
249 return Err("bundle must be a bounded regular file".into());
250 }
251 let mut json = String::new();
252 file.take(MAX_BUNDLE_BYTES as u64 + 1)
253 .read_to_string(&mut json)
254 .map_err(|_| "bundle cannot be read")?;
255 if json.len() > MAX_BUNDLE_BYTES {
256 return Err("bundle exceeds byte limit".into());
257 }
258 parse_bundle_v1(&json)
259}
260
261pub fn import_bundle_v1_into_session(
262 session: &mut SessionState,
263 bundle: &CcpSessionBundleV1,
264 current_project_root: Option<&str>,
265) -> ImportReportV1 {
266 let mut imported = bundle.session.clone();
267
268 if let Some(root) = current_project_root {
270 imported.project_root = Some(root.to_string());
271 }
272
273 let jail_root = imported.project_root.clone().unwrap_or_else(|| {
275 std::env::current_dir()
276 .map_or_else(|_| ".".to_string(), |p| p.to_string_lossy().to_string())
277 });
278 let jail_root_path = PathBuf::from(&jail_root);
279
280 let mut stale = 0u32;
281 for f in &mut imported.files_touched {
282 let candidate = candidate_path(&jail_root_path, &f.path);
283 if let Ok((jailed, _warning)) = crate::core::io_boundary::jail_and_check_path(
284 "ctx_session.import",
285 candidate.as_path(),
286 jail_root_path.as_path(),
287 ) {
288 if jailed.exists() {
289 f.stale = false;
290 } else {
291 f.stale = true;
292 stale += 1;
293 }
294 } else {
295 f.stale = true;
296 stale += 1;
297 }
298 }
299
300 *session = SessionState {
301 canonical_checkpoint: None,
302 handoff_context: Vec::new(),
303 id: SessionState::new().id,
304 version: imported.version,
305 started_at: imported.started_at,
306 updated_at: imported.updated_at,
307 project_root: imported.project_root.clone(),
308 shell_cwd: None,
310 task: imported.task.clone(),
311 findings: imported.findings.clone(),
312 decisions: imported.decisions.clone(),
313 files_touched: imported.files_touched.clone(),
314 test_results: imported.test_results.clone(),
315 progress: imported.progress.clone(),
316 next_steps: imported.next_steps.clone(),
317 evidence: imported.evidence.clone(),
318 intents: Vec::new(),
319 active_structured_intent: None,
320 stats: imported.stats.clone(),
321 terse_mode: imported.terse_mode,
322 compression_level: imported.compression_level.clone(),
323 last_consolidate_ts: None,
324 last_aaak_hash: None,
325 extra_roots: Vec::new(),
326 playbook: crate::core::session::Playbook::default(),
327 last_semantic_query: None,
328 last_flush: None,
329 save_gate: Default::default(),
330 last_save_failed: false,
331 live_zone: Default::default(),
332 };
333
334 use sha2::{Digest as _, Sha256};
335 let source_digest = crate::core::agent_identity::hex_encode(&Sha256::digest(
336 crate::core::canonical::canonical_serialize(bundle),
337 ));
338 session.evidence.push(EvidenceRecord {
341 kind: crate::core::session::EvidenceKind::Manual,
342 key: "session_import_source".into(),
343 value: Some(
344 serde_json::json!({"schema_version":1,
345 "source_session_id":imported.id,
346 "source_digest":format!("sha256:{source_digest}")})
347 .to_string(),
348 ),
349 tool: None,
350 input_md5: None,
351 output_md5: None,
352 agent_id: None,
353 client_name: None,
354 task_id: None,
355 timestamp: Utc::now(),
356 });
357 ImportReportV1 {
358 session_id: session.id.clone(),
359 source_session_id: imported.id,
360 version: session.version,
361 files_touched: session.files_touched.len() as u32,
362 stale_files: stale,
363 }
364}
365
366#[derive(Debug, Clone)]
367pub struct ImportReportV1 {
368 pub session_id: String,
369 pub source_session_id: String,
370 pub version: u32,
371 pub files_touched: u32,
372 pub stale_files: u32,
373}
374
375fn redact_excerpt_in_place(ex: &mut SessionExcerptV1) {
376 ex.shell_cwd = None;
377 ex.project_root = None;
379
380 if let Some(ref mut t) = ex.task {
381 t.description = crate::core::redaction::redact_text(&t.description);
382 if let Some(ref mut intent) = t.intent {
383 *intent = crate::core::redaction::redact_text(intent);
384 }
385 }
386 for f in &mut ex.findings {
387 f.summary = crate::core::redaction::redact_text(&f.summary);
388 if let Some(ref mut file) = f.file {
389 *file = crate::core::redaction::redact_text(file);
390 }
391 }
392 for d in &mut ex.decisions {
393 d.summary = crate::core::redaction::redact_text(&d.summary);
394 if let Some(ref mut r) = d.rationale {
395 *r = crate::core::redaction::redact_text(r);
396 }
397 }
398 for p in &mut ex.progress {
399 p.action = crate::core::redaction::redact_text(&p.action);
400 if let Some(ref mut detail) = p.detail {
401 *detail = crate::core::redaction::redact_text(detail);
402 }
403 }
404 for s in &mut ex.next_steps {
405 *s = crate::core::redaction::redact_text(s);
406 }
407 for ev in &mut ex.evidence {
408 ev.value = None;
409 }
410}
411
412fn strip_root_prefix(root: &str, path: &str) -> Option<String> {
413 let root = root.trim_end_matches(std::path::MAIN_SEPARATOR);
414 let root_prefix = format!("{root}{}", std::path::MAIN_SEPARATOR);
415 if path.starts_with(&root_prefix) {
416 Some(path.trim_start_matches(&root_prefix).to_string())
417 } else {
418 None
419 }
420}
421
422fn candidate_path(jail_root: &Path, stored_path: &str) -> PathBuf {
423 let p = PathBuf::from(stored_path);
424 if p.is_absolute() {
425 p
426 } else {
427 jail_root.join(p)
428 }
429}
430
431#[cfg(test)]
432mod tests {
433 use super::*;
434
435 #[test]
436 fn redacted_export_drops_evidence_values() {
437 let mut s = SessionState::new();
438 s.record_manual_evidence("k", Some("secret=abcdef0123456789abcdef0123456789"));
439 let b = build_bundle_v1(&s, BundlePrivacyV1::Redacted);
440 assert!(b.session.evidence.iter().all(|e| e.value.is_none()));
441 }
442
443 #[test]
444 fn serialize_respects_size_cap() {
445 let s = SessionState::new();
446 let b = build_bundle_v1(&s, BundlePrivacyV1::Redacted);
447 let json = serialize_bundle_v1_pretty(&b).expect("json");
448 assert!(json.len() < MAX_BUNDLE_BYTES);
449 let parsed = parse_bundle_v1(&json).expect("parse");
450 assert_eq!(parsed.schema_version, b.schema_version);
451 }
452
453 #[test]
454 fn import_marks_missing_files_stale() {
455 let mut s = SessionState::new();
456 s.project_root = Some(
457 std::env::current_dir()
458 .unwrap()
459 .to_string_lossy()
460 .to_string(),
461 );
462 s.touch_file("does-not-exist-xyz.txt", None, "full", 10);
463 let b = build_bundle_v1(&s, BundlePrivacyV1::Redacted);
464
465 let root = std::env::current_dir()
466 .unwrap()
467 .to_string_lossy()
468 .to_string();
469 let mut target = SessionState::new();
470 let report = import_bundle_v1_into_session(&mut target, &b, Some(&root));
471 assert_eq!(report.files_touched, 1);
472 assert_eq!(report.stale_files, 1);
473 assert!(target.files_touched[0].stale);
474 }
475
476 fn with_two_findings(root: &str) -> SessionState {
477 std::fs::write(format!("{root}/pool.rs"), "pub fn lease() {}\n").expect("source");
478 let mut session = SessionState::new();
479 session.project_root = Some(root.to_owned());
480 session.add_finding_for_file(Some("pool.rs"), Some(1), "the pool leaks one lease");
481 session.add_finding_for_file(None, None, "noted without a file");
482 session
483 }
484
485 fn exported(session: &SessionState) -> Vec<String> {
486 build_bundle_v1(session, BundlePrivacyV1::Redacted)
487 .session
488 .findings
489 .into_iter()
490 .map(|finding| finding.summary)
491 .collect()
492 }
493
494 #[test]
498 fn a_finding_whose_source_is_gone_is_not_exported() {
499 use crate::core::context_admission::stored::lineage_support::{governed, project, scoped};
500 let _data = governed();
501 let _community = crate::core::policy::runtime::TestPolicyOverride::set(None);
502 let (_directory, root) = project();
503 let session = with_two_findings(&root);
504
505 assert_eq!(
506 scoped(&root, || exported(&session)),
507 ["the pool leaks one lease", "noted without a file"]
508 );
509 std::fs::remove_file(format!("{root}/pool.rs")).expect("delete");
510 assert_eq!(
511 scoped(&root, || exported(&session)),
512 ["noted without a file"]
513 );
514 assert_eq!(session.findings.len(), 2, "the session keeps both");
515 }
516
517 #[test]
519 fn findings_are_exported_as_before_in_developer_mode() {
520 use crate::core::context_admission::stored::lineage_support::project;
521 let _data = crate::core::data_dir::isolated_data_dir();
522 let _community = crate::core::policy::runtime::TestPolicyOverride::set(None);
523 let (_directory, root) = project();
524 let session = with_two_findings(&root);
525 std::fs::remove_file(format!("{root}/pool.rs")).expect("delete");
526 assert_eq!(
527 exported(&session),
528 ["the pool leaks one lease", "noted without a file"]
529 );
530 }
531}