Skip to main content

lean_ctx/core/
ccp_session_bundle.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use std::path::{Path, PathBuf};
4
5use crate::core::session::{
6    Decision, EvidenceRecord, FileTouched, Finding, ProgressEntry, SessionState, SessionStats,
7    TaskInfo, TestSnapshot,
8};
9
10const MAX_BUNDLE_BYTES: usize = 250_000;
11const MAX_NEXT_STEPS: usize = 25;
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum BundlePrivacyV1 {
15    Redacted,
16    Full,
17}
18
19impl BundlePrivacyV1 {
20    pub fn parse(s: Option<&str>) -> Self {
21        match s.unwrap_or("redacted").trim().to_lowercase().as_str() {
22            "full" => Self::Full,
23            _ => Self::Redacted,
24        }
25    }
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct CcpSessionBundleV1 {
30    pub schema_version: u32,
31    pub exported_at: DateTime<Utc>,
32    pub project: ProjectIdentityV1,
33    pub role: PolicyIdentityV1,
34    pub profile: PolicyIdentityV1,
35    pub session: SessionExcerptV1,
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct ProjectIdentityV1 {
40    pub project_root_hash: Option<String>,
41    pub project_identity_hash: Option<String>,
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct PolicyIdentityV1 {
46    pub name: String,
47    pub policy_md5: String,
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct SessionExcerptV1 {
52    pub id: String,
53    pub version: u32,
54    pub started_at: DateTime<Utc>,
55    pub updated_at: DateTime<Utc>,
56    pub project_root: Option<String>,
57    pub shell_cwd: Option<String>,
58    pub task: Option<TaskInfo>,
59    pub findings: Vec<Finding>,
60    pub decisions: Vec<Decision>,
61    pub files_touched: Vec<FileTouched>,
62    pub test_results: Option<TestSnapshot>,
63    pub progress: Vec<ProgressEntry>,
64    pub next_steps: Vec<String>,
65    pub evidence: Vec<EvidenceRecord>,
66    pub stats: SessionStats,
67    #[serde(default)]
68    pub terse_mode: bool,
69    #[serde(default)]
70    pub compression_level: String,
71}
72
73pub fn build_bundle_v1(session: &SessionState, privacy: BundlePrivacyV1) -> CcpSessionBundleV1 {
74    let role_name = crate::core::roles::active_role_name();
75    let role = crate::core::roles::active_role();
76    let profile_name = crate::core::profiles::active_profile_name();
77    let profile = crate::core::profiles::active_profile();
78
79    let role_policy_md5 = md5_hex(&serde_json::to_string(&role).unwrap_or_default());
80    let profile_policy_md5 = md5_hex(&serde_json::to_string(&profile).unwrap_or_default());
81
82    let (project_root_hash, project_identity_hash) =
83        session
84            .project_root
85            .as_deref()
86            .map_or((None, None), |root| {
87                let root_hash = crate::core::project_hash::hash_project_root(root);
88                let identity = crate::core::project_hash::project_identity(root);
89                let identity_hash = identity.as_deref().map(md5_hex);
90                (Some(root_hash), identity_hash)
91            });
92
93    let mut excerpt = SessionExcerptV1 {
94        id: session.id.clone(),
95        version: session.version,
96        started_at: session.started_at,
97        updated_at: session.updated_at,
98        project_root: session.project_root.clone(),
99        shell_cwd: session.shell_cwd.clone(),
100        task: session.task.clone(),
101        findings: session.findings.clone(),
102        decisions: session.decisions.clone(),
103        files_touched: session.files_touched.clone(),
104        test_results: session.test_results.clone(),
105        progress: session.progress.clone(),
106        next_steps: session
107            .next_steps
108            .iter()
109            .take(MAX_NEXT_STEPS)
110            .cloned()
111            .collect(),
112        evidence: session.evidence.clone(),
113        stats: session.stats.clone(),
114        terse_mode: session.terse_mode,
115        compression_level: session.compression_level.clone(),
116    };
117
118    // Path minimization: prefer relative paths when project_root is known.
119    let root = excerpt.project_root.clone().unwrap_or_default();
120    if !root.is_empty() {
121        for f in &mut excerpt.files_touched {
122            if let Some(rel) = strip_root_prefix(&root, &f.path) {
123                f.path = rel;
124            }
125        }
126        for finding in &mut excerpt.findings {
127            if let Some(ref file) = finding.file.clone() {
128                if let Some(rel) = strip_root_prefix(&root, file) {
129                    finding.file = Some(rel);
130                }
131            }
132        }
133    }
134
135    match privacy {
136        BundlePrivacyV1::Full => {
137            // Full export is allowed only for admin role; otherwise force redaction.
138            if role_name != "admin" {
139                redact_excerpt_in_place(&mut excerpt);
140            } else if crate::core::redaction::redaction_enabled_for_active_role() {
141                // Admin opted into redaction — keep it consistent.
142                redact_excerpt_in_place(&mut excerpt);
143            }
144        }
145        BundlePrivacyV1::Redacted => {
146            redact_excerpt_in_place(&mut excerpt);
147        }
148    }
149
150    CcpSessionBundleV1 {
151        schema_version: crate::core::contracts::CCP_SESSION_BUNDLE_V1_SCHEMA_VERSION,
152        exported_at: Utc::now(),
153        project: ProjectIdentityV1 {
154            project_root_hash,
155            project_identity_hash,
156        },
157        role: PolicyIdentityV1 {
158            name: role_name,
159            policy_md5: role_policy_md5,
160        },
161        profile: PolicyIdentityV1 {
162            name: profile_name,
163            policy_md5: profile_policy_md5,
164        },
165        session: excerpt,
166    }
167}
168
169pub fn serialize_bundle_v1_pretty(bundle: &CcpSessionBundleV1) -> Result<String, String> {
170    let json = serde_json::to_string_pretty(bundle).map_err(|e| e.to_string())?;
171    if json.len() > MAX_BUNDLE_BYTES {
172        return Err(format!(
173            "ERROR: bundle too large ({} bytes > max {}). Use privacy=redacted and/or reduce session evidence.",
174            json.len(),
175            MAX_BUNDLE_BYTES
176        ));
177    }
178    Ok(json)
179}
180
181pub fn parse_bundle_v1(json: &str) -> Result<CcpSessionBundleV1, String> {
182    let b: CcpSessionBundleV1 = serde_json::from_str(json).map_err(|e| e.to_string())?;
183    if b.schema_version != crate::core::contracts::CCP_SESSION_BUNDLE_V1_SCHEMA_VERSION {
184        return Err(format!(
185            "ERROR: unsupported schema_version {} (expected {})",
186            b.schema_version,
187            crate::core::contracts::CCP_SESSION_BUNDLE_V1_SCHEMA_VERSION
188        ));
189    }
190    Ok(b)
191}
192
193pub fn write_bundle_v1(path: &Path, json: &str) -> Result<(), String> {
194    let parent = path
195        .parent()
196        .ok_or_else(|| "ERROR: invalid path".to_string())?;
197    if !parent.exists() {
198        std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
199    }
200    let tmp = parent.join(format!(
201        ".{}.tmp",
202        path.file_name()
203            .and_then(|s| s.to_str())
204            .unwrap_or("bundle")
205    ));
206    std::fs::write(&tmp, json).map_err(|e| e.to_string())?;
207    std::fs::rename(&tmp, path).map_err(|e| e.to_string())?;
208    Ok(())
209}
210
211pub fn read_bundle_v1(path: &Path) -> Result<CcpSessionBundleV1, String> {
212    let json = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
213    if json.len() > MAX_BUNDLE_BYTES {
214        return Err(format!(
215            "ERROR: bundle file too large ({} bytes > max {})",
216            json.len(),
217            MAX_BUNDLE_BYTES
218        ));
219    }
220    parse_bundle_v1(&json)
221}
222
223pub fn import_bundle_v1_into_session(
224    session: &mut SessionState,
225    bundle: &CcpSessionBundleV1,
226    current_project_root: Option<&str>,
227) -> ImportReportV1 {
228    let mut imported = bundle.session.clone();
229
230    // Prefer current project root when provided (replay safety).
231    if let Some(root) = current_project_root {
232        imported.project_root = Some(root.to_string());
233    }
234
235    // Mark stale file paths if missing or outside jail.
236    let jail_root = imported.project_root.clone().unwrap_or_else(|| {
237        std::env::current_dir()
238            .map_or_else(|_| ".".to_string(), |p| p.to_string_lossy().to_string())
239    });
240    let jail_root_path = PathBuf::from(&jail_root);
241
242    let mut stale = 0u32;
243    for f in &mut imported.files_touched {
244        let candidate = candidate_path(&jail_root_path, &f.path);
245        if let Ok((jailed, _warning)) = crate::core::io_boundary::jail_and_check_path(
246            "ctx_session.import",
247            candidate.as_path(),
248            jail_root_path.as_path(),
249        ) {
250            if jailed.exists() {
251                f.stale = false;
252            } else {
253                f.stale = true;
254                stale += 1;
255            }
256        } else {
257            f.stale = true;
258            stale += 1;
259        }
260    }
261
262    *session = SessionState {
263        id: imported.id.clone(),
264        version: imported.version,
265        started_at: imported.started_at,
266        updated_at: imported.updated_at,
267        project_root: imported.project_root.clone(),
268        shell_cwd: imported.shell_cwd.clone(),
269        task: imported.task.clone(),
270        findings: imported.findings.clone(),
271        decisions: imported.decisions.clone(),
272        files_touched: imported.files_touched.clone(),
273        test_results: imported.test_results.clone(),
274        progress: imported.progress.clone(),
275        next_steps: imported.next_steps.clone(),
276        evidence: imported.evidence.clone(),
277        intents: Vec::new(),
278        active_structured_intent: None,
279        stats: imported.stats.clone(),
280        terse_mode: imported.terse_mode,
281        compression_level: imported.compression_level.clone(),
282    };
283
284    ImportReportV1 {
285        session_id: session.id.clone(),
286        version: session.version,
287        files_touched: session.files_touched.len() as u32,
288        stale_files: stale,
289    }
290}
291
292#[derive(Debug, Clone)]
293pub struct ImportReportV1 {
294    pub session_id: String,
295    pub version: u32,
296    pub files_touched: u32,
297    pub stale_files: u32,
298}
299
300fn redact_excerpt_in_place(ex: &mut SessionExcerptV1) {
301    ex.shell_cwd = None;
302    // project_root is represented as hashes at bundle level; avoid exporting raw paths.
303    ex.project_root = None;
304
305    if let Some(ref mut t) = ex.task {
306        t.description = crate::core::redaction::redact_text(&t.description);
307        if let Some(ref mut intent) = t.intent {
308            *intent = crate::core::redaction::redact_text(intent);
309        }
310    }
311    for f in &mut ex.findings {
312        f.summary = crate::core::redaction::redact_text(&f.summary);
313        if let Some(ref mut file) = f.file {
314            *file = crate::core::redaction::redact_text(file);
315        }
316    }
317    for d in &mut ex.decisions {
318        d.summary = crate::core::redaction::redact_text(&d.summary);
319        if let Some(ref mut r) = d.rationale {
320            *r = crate::core::redaction::redact_text(r);
321        }
322    }
323    for p in &mut ex.progress {
324        p.action = crate::core::redaction::redact_text(&p.action);
325        if let Some(ref mut detail) = p.detail {
326            *detail = crate::core::redaction::redact_text(detail);
327        }
328    }
329    for s in &mut ex.next_steps {
330        *s = crate::core::redaction::redact_text(s);
331    }
332    for ev in &mut ex.evidence {
333        ev.value = None;
334    }
335}
336
337fn strip_root_prefix(root: &str, path: &str) -> Option<String> {
338    let root = root.trim_end_matches(std::path::MAIN_SEPARATOR);
339    let root_prefix = format!("{root}{}", std::path::MAIN_SEPARATOR);
340    if path.starts_with(&root_prefix) {
341        Some(path.trim_start_matches(&root_prefix).to_string())
342    } else {
343        None
344    }
345}
346
347fn candidate_path(jail_root: &Path, stored_path: &str) -> PathBuf {
348    let p = PathBuf::from(stored_path);
349    if p.is_absolute() {
350        p
351    } else {
352        jail_root.join(p)
353    }
354}
355
356fn md5_hex(text: &str) -> String {
357    use md5::{Digest, Md5};
358    let mut hasher = Md5::new();
359    hasher.update(text.as_bytes());
360    format!("{:x}", hasher.finalize())
361}
362
363#[cfg(test)]
364mod tests {
365    use super::*;
366
367    #[test]
368    fn redacted_export_drops_evidence_values() {
369        let mut s = SessionState::new();
370        s.record_manual_evidence("k", Some("secret=abcdef0123456789abcdef0123456789"));
371        let b = build_bundle_v1(&s, BundlePrivacyV1::Redacted);
372        assert!(b.session.evidence.iter().all(|e| e.value.is_none()));
373    }
374
375    #[test]
376    fn serialize_respects_size_cap() {
377        let s = SessionState::new();
378        let b = build_bundle_v1(&s, BundlePrivacyV1::Redacted);
379        let json = serialize_bundle_v1_pretty(&b).expect("json");
380        assert!(json.len() < MAX_BUNDLE_BYTES);
381        let parsed = parse_bundle_v1(&json).expect("parse");
382        assert_eq!(parsed.schema_version, b.schema_version);
383    }
384
385    #[test]
386    fn import_marks_missing_files_stale() {
387        let mut s = SessionState::new();
388        s.project_root = Some(
389            std::env::current_dir()
390                .unwrap()
391                .to_string_lossy()
392                .to_string(),
393        );
394        s.touch_file("does-not-exist-xyz.txt", None, "full", 10);
395        let b = build_bundle_v1(&s, BundlePrivacyV1::Redacted);
396
397        let root = std::env::current_dir()
398            .unwrap()
399            .to_string_lossy()
400            .to_string();
401        let mut target = SessionState::new();
402        let report = import_bundle_v1_into_session(&mut target, &b, Some(&root));
403        assert_eq!(report.files_touched, 1);
404        assert_eq!(report.stale_files, 1);
405        assert!(target.files_touched[0].stale);
406    }
407}