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(
210 root: &Path,
211 requested: &Path,
212 jailed: &Path,
213 json: &str,
214) -> Result<(), String> {
215 crate::core::pathjail_write::write_requested_below(root, requested, jailed, json.as_bytes())
216 .map(|_| ())
217}
218
219pub fn read_bundle_v1(path: &Path) -> Result<CcpSessionBundleV1, String> {
220 use std::io::Read as _;
221 let mut options = std::fs::OpenOptions::new();
222 options.read(true);
223 #[cfg(unix)]
224 {
225 use std::os::unix::fs::OpenOptionsExt as _;
226 options.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK);
227 }
228 #[cfg(windows)]
229 {
230 use std::os::windows::fs::OpenOptionsExt as _;
231 options.custom_flags(0x0020_0000); }
233 let file = options.open(path).map_err(|_| "bundle cannot be opened")?;
234 let metadata = file.metadata().map_err(|_| "bundle metadata unavailable")?;
235 #[cfg(windows)]
236 {
237 use std::os::windows::fs::MetadataExt as _;
238 if metadata.file_attributes() & 0x0400 != 0 {
239 return Err("bundle reparse point rejected".into());
240 }
241 }
242 if !metadata.is_file() || metadata.len() > MAX_BUNDLE_BYTES as u64 {
243 return Err("bundle must be a bounded regular file".into());
244 }
245 let mut json = String::new();
246 file.take(MAX_BUNDLE_BYTES as u64 + 1)
247 .read_to_string(&mut json)
248 .map_err(|_| "bundle cannot be read")?;
249 if json.len() > MAX_BUNDLE_BYTES {
250 return Err("bundle exceeds byte limit".into());
251 }
252 parse_bundle_v1(&json)
253}
254
255pub fn import_bundle_v1_into_session(
256 session: &mut SessionState,
257 bundle: &CcpSessionBundleV1,
258 current_project_root: Option<&str>,
259) -> ImportReportV1 {
260 let mut imported = bundle.session.clone();
261
262 if let Some(root) = current_project_root {
264 imported.project_root = Some(root.to_string());
265 }
266
267 let jail_root = imported.project_root.clone().unwrap_or_else(|| {
269 std::env::current_dir()
270 .map_or_else(|_| ".".to_string(), |p| p.to_string_lossy().to_string())
271 });
272 let jail_root_path = PathBuf::from(&jail_root);
273
274 let mut stale = 0u32;
275 for f in &mut imported.files_touched {
276 let candidate = candidate_path(&jail_root_path, &f.path);
277 if let Ok((jailed, _warning)) = crate::core::io_boundary::jail_and_check_path(
278 "ctx_session.import",
279 candidate.as_path(),
280 jail_root_path.as_path(),
281 ) {
282 if jailed.exists() {
283 f.stale = false;
284 } else {
285 f.stale = true;
286 stale += 1;
287 }
288 } else {
289 f.stale = true;
290 stale += 1;
291 }
292 }
293
294 *session = SessionState {
295 canonical_checkpoint: None,
296 handoff_context: Vec::new(),
297 id: SessionState::new().id,
298 app_session_id: crate::core::activity::session_id_from_env(),
299 version: imported.version,
300 started_at: imported.started_at,
301 updated_at: imported.updated_at,
302 project_root: imported.project_root.clone(),
303 shell_cwd: None,
305 task: imported.task.clone(),
306 findings: imported.findings.clone(),
307 decisions: imported.decisions.clone(),
308 files_touched: imported.files_touched.clone(),
309 test_results: imported.test_results.clone(),
310 progress: imported.progress.clone(),
311 next_steps: imported.next_steps.clone(),
312 evidence: imported.evidence.clone(),
313 intents: Vec::new(),
314 active_structured_intent: None,
315 stats: imported.stats.clone(),
316 terse_mode: imported.terse_mode,
317 compression_level: imported.compression_level.clone(),
318 last_consolidate_ts: None,
319 last_aaak_hash: None,
320 extra_roots: Vec::new(),
321 playbook: crate::core::session::Playbook::default(),
322 last_semantic_query: None,
323 taint: Default::default(),
324 last_flush: None,
325 save_gate: Default::default(),
326 last_save_failed: false,
327 live_zone: Default::default(),
328 };
329
330 use sha2::{Digest as _, Sha256};
331 let source_digest = crate::core::agent_identity::hex_encode(&Sha256::digest(
332 crate::core::canonical::canonical_serialize(bundle),
333 ));
334 session.evidence.push(EvidenceRecord {
337 kind: crate::core::session::EvidenceKind::Manual,
338 key: "session_import_source".into(),
339 value: Some(
340 serde_json::json!({"schema_version":1,
341 "source_session_id":imported.id,
342 "source_digest":format!("sha256:{source_digest}")})
343 .to_string(),
344 ),
345 tool: None,
346 input_md5: None,
347 output_md5: None,
348 agent_id: None,
349 client_name: None,
350 task_id: None,
351 timestamp: Utc::now(),
352 });
353 ImportReportV1 {
354 session_id: session.id.clone(),
355 source_session_id: imported.id,
356 version: session.version,
357 files_touched: session.files_touched.len() as u32,
358 stale_files: stale,
359 }
360}
361
362#[derive(Debug, Clone)]
363pub struct ImportReportV1 {
364 pub session_id: String,
365 pub source_session_id: String,
366 pub version: u32,
367 pub files_touched: u32,
368 pub stale_files: u32,
369}
370
371fn redact_excerpt_in_place(ex: &mut SessionExcerptV1) {
372 ex.shell_cwd = None;
373 ex.project_root = None;
375
376 if let Some(ref mut t) = ex.task {
377 t.description = crate::core::redaction::redact_text(&t.description);
378 if let Some(ref mut intent) = t.intent {
379 *intent = crate::core::redaction::redact_text(intent);
380 }
381 }
382 for f in &mut ex.findings {
383 f.summary = crate::core::redaction::redact_text(&f.summary);
384 if let Some(ref mut file) = f.file {
385 *file = crate::core::redaction::redact_text(file);
386 }
387 }
388 for d in &mut ex.decisions {
389 d.summary = crate::core::redaction::redact_text(&d.summary);
390 if let Some(ref mut r) = d.rationale {
391 *r = crate::core::redaction::redact_text(r);
392 }
393 }
394 for p in &mut ex.progress {
395 p.action = crate::core::redaction::redact_text(&p.action);
396 if let Some(ref mut detail) = p.detail {
397 *detail = crate::core::redaction::redact_text(detail);
398 }
399 }
400 for s in &mut ex.next_steps {
401 *s = crate::core::redaction::redact_text(s);
402 }
403 for ev in &mut ex.evidence {
404 ev.value = None;
405 }
406}
407
408fn strip_root_prefix(root: &str, path: &str) -> Option<String> {
409 let root = root.trim_end_matches(std::path::MAIN_SEPARATOR);
410 let root_prefix = format!("{root}{}", std::path::MAIN_SEPARATOR);
411 if path.starts_with(&root_prefix) {
412 Some(path.trim_start_matches(&root_prefix).to_string())
413 } else {
414 None
415 }
416}
417
418fn candidate_path(jail_root: &Path, stored_path: &str) -> PathBuf {
419 let p = PathBuf::from(stored_path);
420 if p.is_absolute() {
421 p
422 } else {
423 jail_root.join(p)
424 }
425}
426
427#[cfg(test)]
428mod tests {
429 use super::*;
430
431 #[test]
432 fn redacted_export_drops_evidence_values() {
433 let mut s = SessionState::new();
434 s.record_manual_evidence("k", Some("secret=abcdef0123456789abcdef0123456789"));
435 let b = build_bundle_v1(&s, BundlePrivacyV1::Redacted);
436 assert!(b.session.evidence.iter().all(|e| e.value.is_none()));
437 }
438
439 #[test]
440 fn serialize_respects_size_cap() {
441 let s = SessionState::new();
442 let b = build_bundle_v1(&s, BundlePrivacyV1::Redacted);
443 let json = serialize_bundle_v1_pretty(&b).expect("json");
444 assert!(json.len() < MAX_BUNDLE_BYTES);
445 let parsed = parse_bundle_v1(&json).expect("parse");
446 assert_eq!(parsed.schema_version, b.schema_version);
447 }
448
449 #[test]
450 fn import_marks_missing_files_stale() {
451 let mut s = SessionState::new();
452 s.project_root = Some(
453 std::env::current_dir()
454 .unwrap()
455 .to_string_lossy()
456 .to_string(),
457 );
458 s.touch_file("does-not-exist-xyz.txt", None, "full", 10);
459 let b = build_bundle_v1(&s, BundlePrivacyV1::Redacted);
460
461 let root = std::env::current_dir()
462 .unwrap()
463 .to_string_lossy()
464 .to_string();
465 let mut target = SessionState::new();
466 let report = import_bundle_v1_into_session(&mut target, &b, Some(&root));
467 assert_eq!(report.files_touched, 1);
468 assert_eq!(report.stale_files, 1);
469 assert!(target.files_touched[0].stale);
470 }
471
472 fn with_two_findings(root: &str) -> SessionState {
473 std::fs::write(format!("{root}/pool.rs"), "pub fn lease() {}\n").expect("source");
474 let mut session = SessionState::new();
475 session.project_root = Some(root.to_owned());
476 session.add_finding_for_file(Some("pool.rs"), Some(1), "the pool leaks one lease");
477 session.add_finding_for_file(None, None, "noted without a file");
478 session
479 }
480
481 fn exported(session: &SessionState) -> Vec<String> {
482 build_bundle_v1(session, BundlePrivacyV1::Redacted)
483 .session
484 .findings
485 .into_iter()
486 .map(|finding| finding.summary)
487 .collect()
488 }
489
490 #[test]
494 fn a_finding_whose_source_is_gone_is_not_exported() {
495 use crate::core::context_admission::stored::lineage_support::{governed, project, scoped};
496 let _data = governed();
497 let _community = crate::core::policy::runtime::TestPolicyOverride::set(None);
498 let (_directory, root) = project();
499 let session = with_two_findings(&root);
500
501 assert_eq!(
502 scoped(&root, || exported(&session)),
503 ["the pool leaks one lease", "noted without a file"]
504 );
505 std::fs::remove_file(format!("{root}/pool.rs")).expect("delete");
506 assert_eq!(
507 scoped(&root, || exported(&session)),
508 ["noted without a file"]
509 );
510 assert_eq!(session.findings.len(), 2, "the session keeps both");
511 }
512
513 #[test]
515 fn findings_are_exported_as_before_in_developer_mode() {
516 use crate::core::context_admission::stored::lineage_support::project;
517 let _data = crate::core::data_dir::isolated_data_dir();
518 let _community = crate::core::policy::runtime::TestPolicyOverride::set(None);
519 let (_directory, root) = project();
520 let session = with_two_findings(&root);
521 std::fs::remove_file(format!("{root}/pool.rs")).expect("delete");
522 assert_eq!(
523 exported(&session),
524 ["the pool leaks one lease", "noted without a file"]
525 );
526 }
527}