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
207/// Write a bundle to `requested` (jail-resolved as `jailed`) below the
208/// project `root`, refusing every symlinked component below the root.
209pub 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); // FILE_FLAG_OPEN_REPARSE_POINT
232    }
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    // Prefer current project root when provided (replay safety).
263    if let Some(root) = current_project_root {
264        imported.project_root = Some(root.to_string());
265    }
266
267    // Mark stale file paths if missing or outside jail.
268    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        // A machine-local cwd from the source never changes the target's scope.
304        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    // Append directly: record_manual_evidence may auto-save, whereas this
335    // candidate must remain unpublished until the caller's guarded commit.
336    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    // project_root is represented as hashes at bundle level; avoid exporting raw paths.
374    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    /// A bundle carries a finding bound to a source while the source admits
491    /// and leaves it out once the source is deleted; the finding without a
492    /// file is exported either way.
493    #[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    /// Developer mode: every finding is exported as before.
514    #[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}