Skip to main content

lean_ctx/core/
ccp_session_bundle.rs

1// SPDX-License-Identifier: Apache-2.0
2
3use 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    /// BLAKE3 hex; the serialized name keeps its historical `_md5` suffix.
50    #[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        // Only findings that may be delivered now leave in a bundle
108        // (`SessionState::finding_served`): a finding bound to a source that
109        // no longer admits is left out.
110        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    // Path minimization: prefer relative paths when project_root is known.
133    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            // Full export is allowed only for admin role; otherwise force redaction.
152            if role_name != "admin" {
153                redact_excerpt_in_place(&mut excerpt);
154            } else if crate::core::redaction::redaction_enabled_for_active_role() {
155                // Admin opted into redaction — keep it consistent.
156                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); // FILE_FLAG_OPEN_REPARSE_POINT
238    }
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    // Prefer current project root when provided (replay safety).
269    if let Some(root) = current_project_root {
270        imported.project_root = Some(root.to_string());
271    }
272
273    // Mark stale file paths if missing or outside jail.
274    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        // A machine-local cwd from the source never changes the target's scope.
309        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    // Append directly: record_manual_evidence may auto-save, whereas this
339    // candidate must remain unpublished until the caller's guarded commit.
340    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    // project_root is represented as hashes at bundle level; avoid exporting raw paths.
378    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    /// A bundle carries a finding bound to a source while the source admits
495    /// and leaves it out once the source is deleted; the finding without a
496    /// file is exported either way.
497    #[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    /// Developer mode: every finding is exported as before.
518    #[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}