1mod bounded;
5pub mod claude_code;
6pub mod codex;
7pub mod cursor;
8pub(crate) mod database;
9pub mod git_history;
10pub mod markdown;
11pub mod opencode;
12mod staging;
13
14use std::collections::HashSet;
15use std::path::{Path, PathBuf};
16
17use chrono::Utc;
18use serde_json::Value;
19
20use crate::core::knowledge::KnowledgeFact;
21use staging::{Gateway, Origin, Rules, joined_leaves, mask_strings};
22
23const MAX_FACT_VALUE_CHARS: usize = 500;
24const MAX_ERRORS: usize = 100;
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum ImportSource {
28 ClaudeCode,
29 Codex,
30 Cursor,
31 OpenCode,
32 Docs,
34 Git,
36}
37
38impl ImportSource {
39 #[must_use]
40 pub const fn as_str(self) -> &'static str {
41 match self {
42 Self::ClaudeCode => "claude-code",
43 Self::Codex => "codex",
44 Self::Cursor => "cursor",
45 Self::OpenCode => "opencode",
46 Self::Docs => "docs",
47 Self::Git => "git",
48 }
49 }
50}
51
52#[derive(Debug, Clone, Default)]
53pub struct ImportResult {
54 pub sessions_found: usize,
55 pub facts_extracted: usize,
56 pub files_touched: usize,
57 pub errors: Vec<String>,
58 pub facts: Vec<KnowledgeFact>,
60 pub facts_skipped: usize,
63 pub sources_skipped: usize,
67 pub sources_refused: usize,
71 pub repo_scope: Option<RepoScope>,
74}
75
76#[derive(Debug, Clone, Default, PartialEq, Eq)]
80pub struct RepoScope {
81 pub files: Vec<String>,
82 pub whole: bool,
83}
84
85#[derive(Debug, Clone, Copy, PartialEq, Eq)]
88pub enum SourceRefusal {
89 OutsideRoot,
91 Withheld,
94 TooLarge,
99 RecordTooLarge,
103 UnitAdmissionUnsupported,
110 TooManyFacts,
114 Changed,
117 Unreadable,
119 DatabaseUnreadable,
122 UnsupportedSchema,
124}
125#[derive(Debug, Clone, PartialEq, Eq)]
129pub(crate) struct HistorySource {
130 pub root: PathBuf,
131 pub path: PathBuf,
132}
133
134pub(crate) fn resolve_history_source(
154 history_root: &Path,
155 path: &Path,
156) -> Result<HistorySource, SourceRefusal> {
157 let request = crate::core::admitted_sources::request_root();
158 let named = [PathBuf::from(request), history_root.to_path_buf()];
159 let path = path.canonicalize().map_err(|_| SourceRefusal::Unreadable)?;
160 named
161 .iter()
162 .filter(|root| !root.as_os_str().is_empty())
163 .filter_map(|root| root.canonicalize().ok())
164 .find(|root| path.starts_with(root))
165 .map(|root| HistorySource { root, path })
166 .ok_or(SourceRefusal::OutsideRoot)
167}
168
169pub(crate) fn admit_history_database(
186 history_root: &Path,
187 path: &Path,
188) -> Result<HistorySource, SourceRefusal> {
189 let source = resolve_history_source(history_root, path)?;
190 crate::core::io_boundary::jail_and_check_path("ctx_knowledge", &source.path, &source.root)
191 .map_err(|_| SourceRefusal::Withheld)?;
192 Ok(source)
193}
194
195#[derive(Debug, Clone, Copy, PartialEq, Eq)]
197pub(crate) enum ValueRefusal {
198 Withheld,
200 NotStorable,
203}
204
205#[derive(Debug, Clone)]
224pub(crate) struct AdmittedValue(Value);
225
226impl AdmittedValue {
227 pub(crate) fn admit(original: Value, rules: Rules<'_>) -> Result<Self, ValueRefusal> {
243 let joined = joined_leaves(&original);
244 if joined.is_empty() {
245 return Ok(Self(original));
246 }
247 if admitted_text(&joined, rules)? == joined {
248 return Ok(Self(original));
249 }
250 let mut masked = original;
251 mask_strings(&mut masked, rules)?;
252 let rejoined = joined_leaves(&masked);
253 if admitted_text(&rejoined, rules)? != rejoined {
254 return Err(ValueRefusal::Withheld);
255 }
256 Ok(Self(masked))
257 }
258
259 fn of_admitted_source(value: Value) -> Self {
263 Self(value)
264 }
265
266 #[allow(clippy::unused_self)]
269 pub(crate) fn derive(&self, value: Value) -> Self {
270 Self(value)
271 }
272
273 pub(crate) fn value(&self) -> &Value {
274 &self.0
275 }
276}
277
278fn admitted_text(text: &str, rules: Rules<'_>) -> Result<String, ValueRefusal> {
280 let admission = crate::core::context_admission::carrier::admit_carrier_text_under(
281 text,
282 rules,
283 &crate::core::context_admission::carrier::STORED,
284 )
285 .map_err(|_| ValueRefusal::Withheld)?;
286 if admission.storable {
287 Ok(admission.text)
288 } else {
289 Err(ValueRefusal::NotStorable)
290 }
291}
292
293#[cfg(test)]
296pub(crate) fn admit_column(result: &mut ImportResult, original: Value) -> Option<AdmittedValue> {
297 match AdmittedValue::admit(original, None) {
298 Ok(value) => Some(value),
299 Err(ValueRefusal::Withheld | ValueRefusal::NotStorable) => {
300 result.facts_skipped += 1;
301 None
302 }
303 }
304}
305
306impl ImportResult {
307 pub fn merge(&mut self, mut other: Self) {
308 self.sessions_found += other.sessions_found;
309 self.facts_extracted += other.facts_extracted;
310 self.files_touched += other.files_touched;
311 self.facts_skipped += other.facts_skipped;
312 self.sources_skipped += other.sources_skipped;
313 self.sources_refused += other.sources_refused;
314 self.errors.append(&mut other.errors);
315 self.facts.append(&mut other.facts);
316 }
317
318 pub(crate) fn refuse(&mut self, refusal: SourceRefusal) {
321 match refusal {
322 SourceRefusal::OutsideRoot | SourceRefusal::Withheld | SourceRefusal::Changed => {
323 self.sources_refused += 1;
324 }
325 SourceRefusal::TooLarge
326 | SourceRefusal::RecordTooLarge
327 | SourceRefusal::UnitAdmissionUnsupported
328 | SourceRefusal::TooManyFacts
329 | SourceRefusal::DatabaseUnreadable
330 | SourceRefusal::UnsupportedSchema
331 | SourceRefusal::Unreadable => self.sources_skipped += 1,
332 }
333 }
334}
335
336pub(crate) fn home_dir() -> Option<PathBuf> {
337 std::env::var_os("HOME").map(PathBuf::from)
338}
339
340fn scratch_parent() -> std::io::Result<PathBuf> {
344 let directory = crate::core::data_dir::lean_ctx_data_dir().map_err(std::io::Error::other)?;
345 std::fs::create_dir_all(&directory)?;
346 Ok(directory)
347}
348
349fn read_transcript(root: &Path, path: &Path, gateway: &Gateway) -> Result<String, SourceRefusal> {
361 use crate::core::admitted_sources::{self, SourceRead};
362 let source = resolve_history_source(root, path)?;
363 let size = std::fs::metadata(&source.path)
364 .map_err(|_| SourceRefusal::Unreadable)?
365 .len();
366 if size > crate::core::limits::max_read_bytes() as u64 {
367 return Err(SourceRefusal::TooLarge);
368 }
369 match admitted_sources::read_with_admission(
370 &source.root.to_string_lossy(),
371 &source.path.to_string_lossy(),
372 "ctx_knowledge",
373 gateway,
374 ) {
375 read @ SourceRead::Admitted(_) => read
376 .storable()
377 .map(str::to_owned)
378 .ok_or(SourceRefusal::Withheld),
379 SourceRead::Withheld(_) => Err(SourceRefusal::Withheld),
380 SourceRead::Unavailable(_) => Err(SourceRefusal::Unreadable),
381 }
382}
383
384fn for_each_transcript_line(
396 root: &Path,
397 path: &Path,
398 gateway: &Gateway,
399 mut each: impl FnMut(usize, &str),
400) -> Result<(), SourceRefusal> {
401 match read_transcript(root, path, gateway) {
402 Ok(content) => {
403 for (index, line) in content.lines().enumerate() {
404 each(index + 1, line);
405 }
406 Ok(())
407 }
408 Err(SourceRefusal::TooLarge) => {
409 let source = resolve_history_source(root, path)?;
410 let mut line_number = 0;
411 pinned(&source, || {
412 bounded::admit(&source, gateway)?.for_each_unit(|unit| {
413 for line in unit.lines() {
414 line_number += 1;
415 each(line_number, line);
416 }
417 })
418 })
419 }
420 Err(refusal) => Err(refusal),
421 }
422}
423
424fn pinned<T>(
435 source: &HistorySource,
436 read: impl FnOnce() -> Result<T, SourceRefusal>,
437) -> Result<T, SourceRefusal> {
438 crate::core::policy::runtime::with_project_source_view(&source.root.to_string_lossy(), read)
439 .unwrap_or(Err(SourceRefusal::Withheld))
440}
441
442pub(crate) fn read_jsonl_files(
443 source: ImportSource,
444 root: &Path,
445 files: Vec<PathBuf>,
446) -> ImportResult {
447 let mut result = ImportResult {
448 sessions_found: files.len(),
449 ..ImportResult::default()
450 };
451 let mut touched = HashSet::new();
452 let mut seen = HashSet::new();
453
454 for path in files {
455 let gateway = crate::core::context_admission::carrier::gateway_snapshot();
459 let mut pending = staging::Pending::under(gateway.clone());
460 let session = session_id(source, &path, pending.rules());
461 let read = for_each_transcript_line(root, &path, &gateway, |line_number, line| {
462 if line.trim().is_empty() {
463 return;
464 }
465 match serde_json::from_str::<Value>(line) {
466 Ok(value) => staging::select(&AdmittedValue::of_admitted_source(value), |found| {
467 pending.stage(found);
468 }),
469 Err(error) => pending.error(format!(
470 "{}:{line_number}: invalid JSONL: {error}",
471 path.display()
472 )),
473 }
474 });
475 let _ = pending.settle(read, source, &session, &mut result, &mut touched, &mut seen);
476 }
477
478 result.files_touched = touched.len();
479 result.facts_extracted = result.facts.len();
480 result
481}
482
483pub(crate) fn read_json_file(source: ImportSource, root: &Path, path: &Path) -> ImportResult {
484 let mut result = ImportResult {
485 sessions_found: 1,
486 ..ImportResult::default()
487 };
488 let mut touched = HashSet::new();
489 let mut seen = HashSet::new();
490 let gateway = crate::core::context_admission::carrier::gateway_snapshot();
491 let rules = Some(gateway.admission_policy());
492 let session = session_id(source, path, rules);
493 match read_transcript(root, path, &gateway) {
494 Err(refusal) => result.refuse(refusal),
495 Ok(content) => match serde_json::from_str::<Value>(&content) {
496 Ok(value) => process_admitted(
497 Origin::new(source, &session, rules),
498 &AdmittedValue::of_admitted_source(value),
499 &mut result,
500 &mut touched,
501 &mut seen,
502 ),
503 Err(error) => push_error(
504 &mut result,
505 format!("{}: invalid JSON: {error}", path.display()),
506 ),
507 },
508 }
509
510 result.files_touched = touched.len();
511 result.facts_extracted = result.facts.len();
512 result
513}
514
515#[cfg(test)]
519pub(crate) fn process_value(
520 source: ImportSource,
521 session: &str,
522 value: &Value,
523 result: &mut ImportResult,
524 touched: &mut HashSet<String>,
525 seen: &mut HashSet<(String, String)>,
526) {
527 if let Some(admitted) = admit_column(result, value.clone()) {
528 let origin = Origin::new(source, session, None);
529 process_admitted(origin, &admitted, result, touched, seen);
530 }
531}
532
533fn process_admitted(
537 origin: Origin<'_>,
538 value: &AdmittedValue,
539 result: &mut ImportResult,
540 touched: &mut HashSet<String>,
541 seen: &mut HashSet<(String, String)>,
542) {
543 staging::select(value, |found| {
546 staging::take_over(found, origin, result, touched, seen);
547 });
548}
549
550pub(crate) fn finish_result(result: &mut ImportResult, touched: &HashSet<String>) {
551 result.files_touched = touched.len();
552 result.facts_extracted = result.facts.len();
553}
554
555pub(crate) fn push_error(result: &mut ImportResult, error: String) {
556 if result.errors.len() < MAX_ERRORS {
557 result.errors.push(error);
558 }
559}
560
561pub(crate) fn session_id(source: ImportSource, path: &Path, rules: Rules<'_>) -> String {
562 let name = path
563 .file_name()
564 .and_then(|name| name.to_str())
565 .unwrap_or("unknown-session");
566 session_label(source, name, rules)
567}
568
569pub(crate) fn session_label(source: ImportSource, name: &str, rules: Rules<'_>) -> String {
575 let kept = crate::core::context_admission::carrier::admit_for_store_under(name, rules)
576 .is_ok_and(|admitted| admitted == name);
577 let name = if kept { name } else { "unnamed-session" };
578 format!("{}:{name}", source.as_str())
579}
580
581fn push_fact(
582 result: &mut ImportResult,
583 seen: &mut HashSet<(String, String)>,
584 Origin {
585 source,
586 session,
587 rules,
588 }: Origin<'_>,
589 category: &str,
590 value: &str,
591 confidence: f32,
592) {
593 let value = truncate(value);
594 if value.is_empty() || !seen.insert((category.to_owned(), value.clone())) {
595 return;
596 }
597 let admitted = crate::core::context_admission::carrier::admit_for_store_under(&value, rules);
604 let Ok(value) = admitted else {
605 result.facts_skipped += 1;
606 return;
607 };
608 if value.is_empty() {
609 return;
610 }
611
612 let now = Utc::now();
613 result.facts.push(KnowledgeFact {
614 anchors: Vec::new(),
615 review: None,
616 capture: Default::default(),
617 origin: crate::core::knowledge::FactOrigin::Unverified,
618 category: category.to_owned(),
619 key: String::new(),
620 value,
621 source_session: session.to_owned(),
622 confidence,
623 created_at: now,
624 last_confirmed: now,
625 retrieval_count: 0,
626 last_retrieved: None,
627 valid_from: None,
628 valid_until: None,
629 supersedes: None,
630 confirmation_count: 0,
631 feedback_up: 0,
632 feedback_down: 0,
633 last_feedback: None,
634 privacy: Default::default(),
635 sensitivity: Default::default(),
636 imported_from: Some(source.as_str().to_owned()),
637 archetype: Default::default(),
638 fidelity: None,
639 revision_count: 0,
640 status: None,
641 validity: None,
642 });
643}
644
645pub(crate) struct RepoFact {
648 pub category: &'static str,
649 pub key: String,
652 pub value: String,
653 pub confidence: f32,
654 pub validity: Option<crate::core::knowledge::FactValidity>,
655 pub valid_from: Option<chrono::DateTime<Utc>>,
656 pub anchors: Vec<crate::core::knowledge::anchors::CodeAnchor>,
658}
659
660pub(crate) fn push_repo_fact(
665 result: &mut ImportResult,
666 seen: &mut HashSet<(String, String)>,
667 source: ImportSource,
668 session: &str,
669 rules: Rules<'_>,
670 fact: RepoFact,
671) {
672 let value = truncate(&fact.value);
673 if value.is_empty() || !seen.insert((fact.category.to_owned(), value.clone())) {
674 return;
675 }
676 let Ok(value) = crate::core::context_admission::carrier::admit_for_store_under(&value, rules)
677 else {
678 result.facts_skipped += 1;
679 return;
680 };
681 if value.is_empty() {
682 return;
683 }
684 let now = Utc::now();
685 result.facts.push(KnowledgeFact {
686 origin: crate::core::knowledge::FactOrigin::Unverified,
687 category: fact.category.to_owned(),
688 key: fact.key,
689 value,
690 source_session: session.to_owned(),
691 confidence: fact.confidence,
692 created_at: now,
693 last_confirmed: now,
694 retrieval_count: 0,
695 last_retrieved: None,
696 valid_from: fact.valid_from,
697 valid_until: None,
698 supersedes: None,
699 confirmation_count: 0,
700 feedback_up: 0,
701 feedback_down: 0,
702 last_feedback: None,
703 privacy: Default::default(),
704 sensitivity: Default::default(),
705 imported_from: Some(source.as_str().to_owned()),
706 archetype: crate::core::knowledge::KnowledgeArchetype::infer_from_category(fact.category),
707 fidelity: None,
708 revision_count: 0,
709 status: None,
710 validity: fact.validity,
711 anchors: fact.anchors,
712 review: None,
713 capture: Default::default(),
714 });
715}
716
717fn collect_file_paths(value: &Value, out: &mut Vec<String>) {
718 match value {
719 Value::Array(values) => {
720 for value in values {
721 collect_file_paths(value, out);
722 }
723 }
724 Value::Object(map) => {
725 for (key, value) in map {
726 if matches!(
727 key.as_str(),
728 "file_path" | "filePath" | "path" | "filename" | "file_name"
729 ) {
730 if let Some(path) = value
731 .as_str()
732 .map(str::trim)
733 .filter(|path| !path.is_empty())
734 {
735 out.push(path.to_owned());
736 }
737 }
738 collect_file_paths(value, out);
739 }
740 }
741 _ => {}
742 }
743}
744
745fn collect_strings(value: &Value, out: &mut Vec<String>) {
746 match value {
747 Value::String(text) => out.push(text.to_owned()),
748 Value::Array(values) => {
749 for value in values {
750 collect_strings(value, out);
751 }
752 }
753 Value::Object(map) => {
754 for value in map.values() {
755 collect_strings(value, out);
756 }
757 }
758 _ => {}
759 }
760}
761
762fn contains_assistant_message(value: &Value) -> bool {
763 match value {
764 Value::Array(values) => values.iter().any(contains_assistant_message),
765 Value::Object(map) => {
766 map.get("role").and_then(Value::as_str) == Some("assistant")
767 || matches!(
768 map.get("type").and_then(Value::as_str),
769 Some("assistant" | "assistant_message")
770 )
771 || map.values().any(contains_assistant_message)
772 }
773 _ => false,
774 }
775}
776
777fn matching_snippets(text: &str, predicate: fn(&str) -> bool) -> Vec<String> {
778 text.split(['\n', '.', '!', '?'])
779 .map(str::trim)
780 .filter(|snippet| snippet.len() >= 4 && predicate(snippet))
781 .map(str::to_owned)
782 .collect()
783}
784
785fn is_decision(text: &str) -> bool {
786 let text = text.to_ascii_lowercase();
787 [
788 "decision",
789 "decided",
790 "we chose",
791 "i chose",
792 "chosen",
793 "will use",
794 "we'll use",
795 "we will use",
796 "going with",
797 "approach is",
798 ]
799 .iter()
800 .any(|marker| text.contains(marker))
801}
802
803fn is_error(text: &str) -> bool {
804 let text = text.to_ascii_lowercase();
805 [
806 "error",
807 "failed",
808 "failure",
809 "panic",
810 "unable to",
811 "cannot ",
812 "can't ",
813 ]
814 .iter()
815 .any(|marker| text.contains(marker))
816}
817
818fn truncate(value: &str) -> String {
819 value[..value.floor_char_boundary(MAX_FACT_VALUE_CHARS)]
825 .trim()
826 .to_owned()
827}
828
829#[cfg(test)]
830mod tests {
831 use super::*;
832
833 #[test]
834 fn truncates_multibyte_facts_without_panicking() {
835 let value = format!("We decided {}", "é".repeat(300));
836 let truncated = truncate(&value);
837 assert!(truncated.len() <= MAX_FACT_VALUE_CHARS);
838 assert!(truncated.is_char_boundary(truncated.len()));
839 }
840
841 #[test]
842 fn extracts_touches_decisions_and_errors() {
843 let value: Value = serde_json::json!({
844 "role": "assistant",
845 "content": "We decided to use a cache. Build failed because input is invalid.",
846 "tool_input": { "file_path": "rust/src/lib.rs" }
847 });
848 let mut result = ImportResult::default();
849 let mut touched = HashSet::new();
850 let mut seen = HashSet::new();
851
852 process_value(
853 ImportSource::Codex,
854 "codex:test",
855 &value,
856 &mut result,
857 &mut touched,
858 &mut seen,
859 );
860 finish_result(&mut result, &touched);
861
862 assert_eq!(result.files_touched, 1);
863 assert!(
864 result
865 .facts
866 .iter()
867 .any(|fact| fact.category == "imported-decision")
868 );
869 assert!(
870 result
871 .facts
872 .iter()
873 .any(|fact| fact.value.starts_with("Observed error:"))
874 );
875 }
876
877 const AWS_KEY: &str = concat!("AK", "IAIOSFODNN7EXAMPLE");
878
879 fn history() -> (tempfile::TempDir, PathBuf, PathBuf) {
882 let dir = tempfile::tempdir().expect("history dir");
883 let root = dir.path().canonicalize().expect("history root");
884 let sessions = root.join("project-a");
885 std::fs::create_dir_all(&sessions).expect("session dir");
886 (dir, root, sessions)
887 }
888
889 fn assistant_line(text: &str) -> String {
890 serde_json::json!({ "message": { "role": "assistant", "content": text } }).to_string()
891 }
892
893 fn decisions(result: &ImportResult) -> Vec<&str> {
894 result
895 .facts
896 .iter()
897 .filter(|fact| fact.category == "imported-decision")
898 .map(|fact| fact.value.as_str())
899 .collect()
900 }
901
902 #[test]
907 fn a_transcript_is_read_admitted_and_its_facts_are_admitted_where_they_are_built() {
908 let _data = crate::core::data_dir::isolated_data_dir();
909 let _community = crate::core::policy::runtime::TestPolicyOverride::set(None);
910 let (_dir, root, sessions) = history();
911 std::fs::write(
912 sessions.join("session.jsonl"),
913 format!(
914 "{}\n{}\n",
915 assistant_line("We decided to use JWT RS256 for the session tokens"),
916 assistant_line(&format!(
917 "We decided to deploy with the key {AWS_KEY} today"
918 )),
919 ),
920 )
921 .expect("transcript");
922
923 let result = claude_code::import_from_root(&root);
924 assert!(result.errors.is_empty(), "{:?}", result.errors);
925 assert_eq!(result.sessions_found, 1);
926 let decisions = decisions(&result);
927 assert!(
928 decisions.contains(&"We decided to use JWT RS256 for the session tokens"),
929 "a clean fact keeps its bytes: {decisions:?}"
930 );
931 assert!(
932 result
933 .facts
934 .iter()
935 .all(|fact| !fact.value.contains(AWS_KEY)),
936 "stored masked, or skipped — never raw"
937 );
938 assert_eq!(decisions.len() + result.facts_skipped, 2);
940 assert_eq!(result.facts_extracted, result.facts.len());
941 }
942
943 #[test]
946 fn a_restricted_value_is_skipped_and_counted() {
947 let _data = crate::core::data_dir::isolated_data_dir();
948 let policy = crate::core::policy::load(
949 "name = \"import-admission-test\"\nversion = \"1.0.0\"\ndescription = \"test\"\n\
950 [filters]\nclassification = \"block\"\nblocked_labels = [\"CONFIDENTIAL\"]\n",
951 )
952 .expect("policy");
953 let _policy = crate::core::policy::runtime::TestPolicyOverride::set(Some(policy));
954 let mut result = ImportResult::default();
955 let mut seen = HashSet::new();
956
957 for value in [
958 "We decided to use a cache",
959 "CONFIDENTIAL\nWe decided to ship roadmap-marker in May",
960 "CONFIDENTIAL\nWe decided to ship roadmap-marker in May",
962 ] {
963 push_fact(
964 &mut result,
965 &mut seen,
966 Origin::new(ImportSource::Codex, "codex:test", None),
967 "imported-decision",
968 value,
969 0.65,
970 );
971 }
972
973 assert_eq!(decisions(&result), vec!["We decided to use a cache"]);
974 assert_eq!(result.facts_skipped, 1);
975 assert!(result.errors.is_empty(), "nothing is reported about it");
976
977 let mut merged = ImportResult::default();
978 merged.merge(result);
979 assert_eq!(merged.facts_skipped, 1, "the count survives a merge");
980 }
981
982 #[cfg(unix)]
985 #[test]
986 fn a_transcript_outside_every_root_is_not_read() {
987 let _data = crate::core::data_dir::isolated_data_dir();
988 let _community = crate::core::policy::runtime::TestPolicyOverride::set(None);
989 let (_dir, root, sessions) = history();
990 let elsewhere = tempfile::tempdir().expect("outside dir");
991 let outside = elsewhere
992 .path()
993 .canonicalize()
994 .expect("outside root")
995 .join("outside.jsonl");
996 std::fs::write(
997 &outside,
998 format!(
999 "{}\n",
1000 assistant_line("We decided to keep outside-marker secret")
1001 ),
1002 )
1003 .expect("outside transcript");
1004 std::os::unix::fs::symlink(&outside, sessions.join("linked.jsonl")).expect("symlink");
1005
1006 let result = claude_code::import_from_root(&root);
1007 assert!(result.facts.is_empty(), "{:?}", decisions(&result));
1008 assert_eq!(result.facts_skipped, 0, "not read, so nothing was judged");
1009 assert_eq!(result.sources_refused, 1, "counted as refused");
1010 assert!(
1011 result.errors.is_empty(),
1012 "no path, no text: {:?}",
1013 result.errors
1014 );
1015
1016 let inside = sessions.join("inside.jsonl");
1019 std::fs::write(&inside, "{}\n").expect("inside transcript");
1020 assert_eq!(
1021 resolve_history_source(&root, &inside),
1022 Ok(HistorySource {
1023 root: root.clone(),
1024 path: inside,
1025 })
1026 );
1027 assert_eq!(
1028 resolve_history_source(&root, &outside),
1029 Err(SourceRefusal::OutsideRoot)
1030 );
1031 assert_eq!(
1032 resolve_history_source(&root, &sessions.join("linked.jsonl")),
1033 Err(SourceRefusal::OutsideRoot),
1034 "a link inside the root that points outside"
1035 );
1036 assert_eq!(
1037 resolve_history_source(&root, &root.join("missing.db")),
1038 Err(SourceRefusal::Unreadable)
1039 );
1040 assert!(
1041 !SourceRefusal::OutsideRoot
1042 .to_string()
1043 .contains("outside.jsonl")
1044 );
1045 }
1046
1047 #[test]
1052 fn history_files_inside_their_named_roots_are_read() {
1053 let _data = crate::core::data_dir::isolated_data_dir();
1054 let _community = crate::core::policy::runtime::TestPolicyOverride::set(None);
1055 let home = tempfile::tempdir().expect("home");
1056 let home = home.path().canonicalize().expect("home root");
1057
1058 let claude_root = home.join(".claude").join("projects");
1059 let claude_sessions = claude_root.join("-Users-dev-project");
1060 std::fs::create_dir_all(&claude_sessions).expect("claude dir");
1061 std::fs::write(
1062 claude_sessions.join("0b1c.jsonl"),
1063 format!(
1064 "{}\n",
1065 assistant_line("We decided to keep the claude marker")
1066 ),
1067 )
1068 .expect("claude transcript");
1069 let claude = claude_code::import_from_root(&claude_root);
1070 assert_eq!((claude.sources_refused, claude.sources_skipped), (0, 0));
1071 assert!(claude.errors.is_empty(), "{:?}", claude.errors);
1072 assert_eq!(
1073 decisions(&claude),
1074 vec!["We decided to keep the claude marker"]
1075 );
1076
1077 let codex_root = home.join(".codex");
1078 let codex_sessions = codex_root.join("sessions").join("2026");
1079 std::fs::create_dir_all(&codex_sessions).expect("codex dir");
1080 std::fs::write(
1081 codex_sessions.join("rollout-1.jsonl"),
1082 format!(
1083 "{}\n",
1084 assistant_line("We decided to keep the codex marker")
1085 ),
1086 )
1087 .expect("codex transcript");
1088 let codex = codex::import_from_root(&codex_root);
1089 assert_eq!((codex.sources_refused, codex.sources_skipped), (0, 0));
1090 assert!(codex.errors.is_empty(), "{:?}", codex.errors);
1091 assert_eq!(
1092 decisions(&codex),
1093 vec!["We decided to keep the codex marker"]
1094 );
1095 }
1096
1097 #[test]
1102 fn a_transcript_above_the_read_cap_is_skipped_and_counted() {
1103 let _data = crate::core::data_dir::isolated_data_dir();
1104 let _community = crate::core::policy::runtime::TestPolicyOverride::set(None);
1105 let (_dir, root, sessions) = history();
1106 let first = assistant_line("We decided to keep the early marker");
1107 let filler = assistant_line(&"x".repeat(4096));
1108 std::fs::write(sessions.join("large.jsonl"), format!("{first}\n{filler}\n"))
1109 .expect("large transcript");
1110
1111 crate::test_env::set_var("LCTX_MAX_READ_BYTES", "1024");
1112 let result = claude_code::import_from_root(&root);
1113 let rules = crate::core::context_admission::carrier::gateway_snapshot();
1114 let reason = resolve_history_source(&root, &sessions.join("large.jsonl"))
1115 .and_then(|source| pinned(&source, || bounded::admit(&source, &rules).map(|_| ())));
1116 crate::test_env::remove_var("LCTX_MAX_READ_BYTES");
1117
1118 assert_eq!(reason, Err(SourceRefusal::RecordTooLarge));
1119 assert_eq!(result.sources_skipped, 1);
1120 assert_eq!(result.sources_refused, 0);
1121 assert!(result.facts.is_empty(), "not even the part below the cap");
1122 assert!(result.errors.is_empty(), "{:?}", result.errors);
1123 }
1124
1125 #[test]
1128 fn a_session_label_is_kept_only_if_it_is_admitted_unchanged() {
1129 let _data = crate::core::data_dir::isolated_data_dir();
1130 let _community = crate::core::policy::runtime::TestPolicyOverride::set(None);
1131 assert_eq!(
1132 session_label(ImportSource::OpenCode, "ses_01HZX", None),
1133 "opencode:ses_01HZX"
1134 );
1135 let label = session_label(
1136 ImportSource::Codex,
1137 &format!("rollout {AWS_KEY}.jsonl"),
1138 None,
1139 );
1140 assert_eq!(label, "codex:unnamed-session");
1141 }
1142
1143 const BLOCK_CONFIDENTIAL: &str = "name = \"import-original-test\"\nversion = \"1.0.0\"\n\
1144 description = \"test\"\n[filters]\nclassification = \"block\"\n\
1145 blocked_labels = [\"CONFIDENTIAL\"]\n";
1146
1147 fn block_confidential() -> crate::core::policy::runtime::TestPolicyOverride {
1148 let policy = crate::core::policy::load(BLOCK_CONFIDENTIAL).expect("policy");
1149 crate::core::policy::runtime::TestPolicyOverride::set(Some(policy))
1150 }
1151
1152 fn codex_database(path: &Path, rows: &[&str]) {
1154 let db = rusqlite::Connection::open(path).expect("database");
1155 db.execute_batch("CREATE TABLE history (id INTEGER PRIMARY KEY, body TEXT NOT NULL);")
1156 .expect("schema");
1157 for row in rows {
1158 db.execute("INSERT INTO history (body) VALUES (?1)", [row])
1159 .expect("row");
1160 }
1161 }
1162
1163 #[test]
1167 fn a_marker_anywhere_in_an_original_value_refuses_the_whole_value() {
1168 let _data = crate::core::data_dir::isolated_data_dir();
1169 let _policy = block_confidential();
1170 let marked = serde_json::json!({
1171 "role": "assistant",
1172 "content": "We decided to keep the marked sentence",
1173 "banner": "CONFIDENTIAL",
1174 });
1175 assert!(AdmittedValue::admit(marked.clone(), None).is_err());
1176
1177 let mut result = ImportResult::default();
1178 assert!(admit_column(&mut result, marked).is_none());
1179 assert_eq!(result.facts_skipped, 1, "counted once per original value");
1180
1181 let clean = serde_json::json!({
1182 "role": "assistant",
1183 "content": "We decided to use a bounded cache",
1184 });
1185 let admitted = AdmittedValue::admit(clean.clone(), None).expect("a clean value");
1186 assert_eq!(admitted.value(), &clean, "a clean value keeps its bytes");
1187 let derived = admitted.derive(serde_json::json!({ "role": "assistant" }));
1188 assert_eq!(derived.value()["role"], "assistant");
1189 }
1190
1191 #[test]
1193 fn a_masked_original_is_only_ever_seen_masked() {
1194 let _data = crate::core::data_dir::isolated_data_dir();
1195 let _community = crate::core::policy::runtime::TestPolicyOverride::set(None);
1196 let keyed = serde_json::json!({
1197 "role": "assistant",
1198 "content": format!("We decided to deploy today. The key is {AWS_KEY}"),
1199 });
1200 match AdmittedValue::admit(keyed, None) {
1201 Ok(admitted) => {
1202 let text = admitted.value().to_string();
1203 assert!(!text.contains(AWS_KEY), "{text}");
1204 assert!(text.contains("We decided to deploy today"), "{text}");
1205 }
1206 Err(refusal) => assert!(
1208 matches!(refusal, ValueRefusal::NotStorable | ValueRefusal::Withheld),
1209 "{refusal:?}"
1210 ),
1211 }
1212 }
1213
1214 #[test]
1217 fn a_marked_codex_column_value_yields_no_fact() {
1218 let _data = crate::core::data_dir::isolated_data_dir();
1219 let (_dir, root, _sessions) = history();
1220 codex_database(
1221 &root.join("history.sqlite"),
1222 &[
1223 r#"{"role":"assistant","content":"We decided to keep the marked sentence","banner":"CONFIDENTIAL"}"#,
1224 r#"{"role":"assistant","content":"We decided to use a bounded cache"}"#,
1225 ],
1226 );
1227
1228 let _policy = block_confidential();
1229 let result = codex::import_from_root(&root);
1230 assert_eq!(
1231 decisions(&result),
1232 vec!["We decided to use a bounded cache"]
1233 );
1234 assert_eq!(result.facts_skipped, 1);
1235 assert_eq!((result.sources_refused, result.sources_skipped), (0, 0));
1236 assert!(
1237 result
1238 .facts
1239 .iter()
1240 .all(|fact| !fact.value.contains("marked sentence"))
1241 );
1242
1243 let broken = rusqlite::Connection::open(root.join("broken.sqlite")).expect("database");
1248 broken
1249 .execute_batch(
1250 "CREATE TABLE history (id INTEGER PRIMARY KEY, body TEXT NOT NULL); \
1251 INSERT INTO history (body) VALUES \
1252 ('{\"role\":\"assistant\",\"content\":\"We decided to keep the early row\"}'), \
1253 ('not json'); \
1254 ALTER TABLE history ADD COLUMN kind TEXT \
1255 GENERATED ALWAYS AS (json_extract(body, '$.role')) VIRTUAL;",
1256 )
1257 .expect("fixture");
1258 drop(broken);
1259 let again = codex::import_from_root(&root);
1260 assert_eq!(decisions(&again), decisions(&result));
1261 assert_eq!((again.sources_skipped, again.sources_refused), (1, 0));
1262 assert_eq!(
1263 (again.sessions_found, again.facts_skipped),
1264 (result.sessions_found, 1)
1265 );
1266 assert_eq!(again.errors.len(), 1, "{:?}", again.errors);
1267 assert!(again.errors[0].ends_with(": history database could not be read; skipped"));
1268 }
1269
1270 #[test]
1273 fn a_keyed_codex_column_value_never_yields_the_key() {
1274 let _data = crate::core::data_dir::isolated_data_dir();
1275 let _community = crate::core::policy::runtime::TestPolicyOverride::set(None);
1276 let (_dir, root, _sessions) = history();
1277 let row = serde_json::json!({
1278 "role": "assistant",
1279 "content": format!("We decided to deploy today. The key is {AWS_KEY}"),
1280 })
1281 .to_string();
1282 codex_database(&root.join("history.sqlite"), &[&row]);
1283
1284 let result = codex::import_from_root(&root);
1285 assert_eq!(result.sources_refused, 0);
1286 assert!(
1287 result
1288 .facts
1289 .iter()
1290 .all(|fact| !fact.value.contains(AWS_KEY))
1291 );
1292 if result.facts_skipped == 0 {
1293 assert!(
1294 decisions(&result).contains(&"We decided to deploy today"),
1295 "the clean sentence of the masked value is imported: {:?}",
1296 decisions(&result)
1297 );
1298 }
1299 }
1300
1301 #[test]
1305 fn a_marked_transcript_yields_no_fact() {
1306 let _data = crate::core::data_dir::isolated_data_dir();
1307 let (_dir, root, sessions) = history();
1308 std::fs::write(
1309 sessions.join("session.jsonl"),
1310 format!(
1311 "CONFIDENTIAL\n{}\n",
1312 assistant_line("We decided to keep the marked sentence")
1313 ),
1314 )
1315 .expect("transcript");
1316
1317 let _policy = block_confidential();
1318 let result = claude_code::import_from_root(&root);
1319 assert!(result.facts.is_empty(), "{:?}", decisions(&result));
1320 assert_eq!(result.sources_refused, 1);
1321 assert_eq!(result.facts_skipped, 0, "the file was refused as a whole");
1322 assert!(result.errors.is_empty(), "{:?}", result.errors);
1323 }
1324
1325 #[test]
1329 fn a_database_at_a_refused_path_is_not_opened() {
1330 let _data = crate::core::data_dir::isolated_data_dir();
1331 let _community = crate::core::policy::runtime::TestPolicyOverride::set(None);
1332 let (_dir, root, _sessions) = history();
1333 let secret_dir = root.join(".aws");
1334 std::fs::create_dir_all(&secret_dir).expect("secret-like dir");
1335 let database = secret_dir.join("history.sqlite");
1336 codex_database(
1337 &database,
1338 &[r#"{"role":"assistant","content":"We decided to use a bounded cache"}"#],
1339 );
1340
1341 crate::test_env::set_var("LEAN_CTX_IO_BOUNDARY_MODE", "enforce");
1342 let decision = admit_history_database(&root, &database);
1343 let result = codex::import_from_root(&root);
1344 crate::test_env::remove_var("LEAN_CTX_IO_BOUNDARY_MODE");
1345
1346 assert_eq!(decision, Err(SourceRefusal::Withheld));
1347 assert_eq!(result.sources_refused, 1);
1348 assert_eq!(result.facts_skipped, 0);
1349 assert!(result.facts.is_empty(), "{:?}", decisions(&result));
1350
1351 crate::test_env::set_var("LEAN_CTX_IO_BOUNDARY_MODE", "warn");
1355 let warned = admit_history_database(&root, &database);
1356 crate::test_env::remove_var("LEAN_CTX_IO_BOUNDARY_MODE");
1357 assert!(warned.is_ok());
1358 }
1359
1360 #[test]
1369 fn a_transcript_above_the_read_cap_is_imported_in_admitted_units() {
1370 let _data = crate::core::data_dir::isolated_data_dir();
1371 let policy = crate::core::policy::load(&format!("{BLOCK_CONFIDENTIAL}pii = \"block\"\n"))
1372 .expect("policy");
1373 let _policy = crate::core::policy::runtime::TestPolicyOverride::set(Some(policy));
1374 let _cap = crate::test_env::EnvVarGuard::set("LCTX_MAX_READ_BYTES", "1024");
1375 let (_dir, root, sessions) = history();
1376 let expected: Vec<String> = ('a'..='c')
1378 .flat_map(|x| ('a'..='t').map(move |y| (x, y)))
1379 .map(|(x, y)| format!("We decided to keep the unit marker é {x}{y}"))
1380 .collect();
1381 let records: Vec<String> = expected.iter().map(|text| assistant_line(text)).collect();
1382 let write = |records: &[String], end: &str| {
1383 std::fs::write(sessions.join("long.jsonl"), records.join("\n") + "\n" + end)
1384 .expect("transcript");
1385 };
1386 let width = records[0].len();
1387 let per_unit = bounded::unit_bytes() / (width + 1);
1388 assert!(records.len() > 3 * per_unit, "several units");
1389
1390 write(&records, "");
1391 let result = claude_code::import_from_root(&root);
1392 assert_eq!((result.sources_skipped, result.sources_refused), (0, 0));
1393 assert!(result.errors.is_empty(), "{:?}", result.errors);
1394 assert_eq!(decisions(&result), expected);
1395
1396 write(&records, "CONFIDENTIAL\n");
1397 let result = claude_code::import_from_root(&root);
1398 assert!(result.facts.is_empty(), "{:?}", decisions(&result));
1399 assert_eq!((result.sources_skipped, result.sources_refused), (0, 1));
1400
1401 use crate::core::context_admission::cross_line::pattern_is_single_line;
1402 assert!(pattern_is_single_line(r"EMP-\d{4}") && !pattern_is_single_line(r"left\s+right"));
1403
1404 write(&records, "");
1409 std::fs::write(sessions.join("next.jsonl"), format!("{}\n", records[0])).expect("next");
1410 bounded::FAIL_BEFORE_UNIT.with(|fault| fault.set(Some(1)));
1411 let result = claude_code::import_from_root(&root);
1412 bounded::FAIL_BEFORE_UNIT.with(|fault| fault.set(None));
1413 assert_eq!(decisions(&result), vec![expected[0].as_str()]);
1414 assert_eq!(
1415 (
1416 result.sources_skipped,
1417 result.sources_refused,
1418 result.facts_skipped
1419 ),
1420 (1, 0, 0)
1421 );
1422 assert_eq!((result.facts_extracted, result.errors.len()), (1, 0));
1423 }
1424
1425 #[cfg(unix)]
1430 #[test]
1431 fn a_database_exchanged_after_verification_is_refused() {
1432 let _data = crate::core::data_dir::isolated_data_dir();
1433 let _community = crate::core::policy::runtime::TestPolicyOverride::set(None);
1434 let (_dir, root, sessions) = history();
1435 let database = root.join("history.sqlite");
1436 let other = sessions.join("other.sqlite");
1437 let row = r#"{"role":"assistant","content":"We decided to keep the verified marker"}"#;
1438 codex_database(&database, &[row]);
1439 codex_database(&other, &[row]);
1440
1441 let verified = database::verify_history_database(&root, &database).expect("verified");
1442 std::fs::rename(&other, &database).expect("rename over");
1443 assert_eq!(verified.into_snapshot().err(), Some(SourceRefusal::Changed));
1444
1445 let verified = database::verify_history_database(&root, &database).expect("verified");
1446 std::fs::rename(&database, &other).expect("move away");
1447 std::os::unix::fs::symlink(&other, &database).expect("link in its place");
1448 assert_eq!(verified.into_snapshot().err(), Some(SourceRefusal::Changed));
1449 }
1450}