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(text, rules)
281 .map_err(|_| ValueRefusal::Withheld)?;
282 if admission.storable {
283 Ok(admission.text)
284 } else {
285 Err(ValueRefusal::NotStorable)
286 }
287}
288
289#[cfg(test)]
292pub(crate) fn admit_column(result: &mut ImportResult, original: Value) -> Option<AdmittedValue> {
293 match AdmittedValue::admit(original, None) {
294 Ok(value) => Some(value),
295 Err(ValueRefusal::Withheld | ValueRefusal::NotStorable) => {
296 result.facts_skipped += 1;
297 None
298 }
299 }
300}
301
302impl ImportResult {
303 pub fn merge(&mut self, mut other: Self) {
304 self.sessions_found += other.sessions_found;
305 self.facts_extracted += other.facts_extracted;
306 self.files_touched += other.files_touched;
307 self.facts_skipped += other.facts_skipped;
308 self.sources_skipped += other.sources_skipped;
309 self.sources_refused += other.sources_refused;
310 self.errors.append(&mut other.errors);
311 self.facts.append(&mut other.facts);
312 }
313
314 pub(crate) fn refuse(&mut self, refusal: SourceRefusal) {
317 match refusal {
318 SourceRefusal::OutsideRoot | SourceRefusal::Withheld | SourceRefusal::Changed => {
319 self.sources_refused += 1;
320 }
321 SourceRefusal::TooLarge
322 | SourceRefusal::RecordTooLarge
323 | SourceRefusal::UnitAdmissionUnsupported
324 | SourceRefusal::TooManyFacts
325 | SourceRefusal::DatabaseUnreadable
326 | SourceRefusal::UnsupportedSchema
327 | SourceRefusal::Unreadable => self.sources_skipped += 1,
328 }
329 }
330}
331
332pub(crate) fn home_dir() -> Option<PathBuf> {
333 std::env::var_os("HOME").map(PathBuf::from)
334}
335
336fn scratch_parent() -> std::io::Result<PathBuf> {
340 let directory = crate::core::data_dir::lean_ctx_data_dir().map_err(std::io::Error::other)?;
341 std::fs::create_dir_all(&directory)?;
342 Ok(directory)
343}
344
345fn read_transcript(root: &Path, path: &Path, gateway: &Gateway) -> Result<String, SourceRefusal> {
357 use crate::core::admitted_sources::{self, SourceRead};
358 let source = resolve_history_source(root, path)?;
359 let size = std::fs::metadata(&source.path)
360 .map_err(|_| SourceRefusal::Unreadable)?
361 .len();
362 if size > crate::core::limits::max_read_bytes() as u64 {
363 return Err(SourceRefusal::TooLarge);
364 }
365 match admitted_sources::read_with_admission(
366 &source.root.to_string_lossy(),
367 &source.path.to_string_lossy(),
368 "ctx_knowledge",
369 gateway,
370 ) {
371 read @ SourceRead::Admitted(_) => read
372 .storable()
373 .map(str::to_owned)
374 .ok_or(SourceRefusal::Withheld),
375 SourceRead::Withheld(_) => Err(SourceRefusal::Withheld),
376 SourceRead::Unavailable(_) => Err(SourceRefusal::Unreadable),
377 }
378}
379
380fn for_each_transcript_line(
392 root: &Path,
393 path: &Path,
394 gateway: &Gateway,
395 mut each: impl FnMut(usize, &str),
396) -> Result<(), SourceRefusal> {
397 match read_transcript(root, path, gateway) {
398 Ok(content) => {
399 for (index, line) in content.lines().enumerate() {
400 each(index + 1, line);
401 }
402 Ok(())
403 }
404 Err(SourceRefusal::TooLarge) => {
405 let source = resolve_history_source(root, path)?;
406 let mut line_number = 0;
407 pinned(&source, || {
408 bounded::admit(&source, gateway)?.for_each_unit(|unit| {
409 for line in unit.lines() {
410 line_number += 1;
411 each(line_number, line);
412 }
413 })
414 })
415 }
416 Err(refusal) => Err(refusal),
417 }
418}
419
420fn pinned<T>(
431 source: &HistorySource,
432 read: impl FnOnce() -> Result<T, SourceRefusal>,
433) -> Result<T, SourceRefusal> {
434 crate::core::policy::runtime::with_project_source_view(&source.root.to_string_lossy(), read)
435 .unwrap_or(Err(SourceRefusal::Withheld))
436}
437
438pub(crate) fn read_jsonl_files(
439 source: ImportSource,
440 root: &Path,
441 files: Vec<PathBuf>,
442) -> ImportResult {
443 let mut result = ImportResult {
444 sessions_found: files.len(),
445 ..ImportResult::default()
446 };
447 let mut touched = HashSet::new();
448 let mut seen = HashSet::new();
449
450 for path in files {
451 let gateway = crate::core::context_admission::carrier::gateway_snapshot();
455 let mut pending = staging::Pending::under(gateway.clone());
456 let session = session_id(source, &path, pending.rules());
457 let read = for_each_transcript_line(root, &path, &gateway, |line_number, line| {
458 if line.trim().is_empty() {
459 return;
460 }
461 match serde_json::from_str::<Value>(line) {
462 Ok(value) => staging::select(&AdmittedValue::of_admitted_source(value), |found| {
463 pending.stage(found);
464 }),
465 Err(error) => pending.error(format!(
466 "{}:{line_number}: invalid JSONL: {error}",
467 path.display()
468 )),
469 }
470 });
471 let _ = pending.settle(read, source, &session, &mut result, &mut touched, &mut seen);
472 }
473
474 result.files_touched = touched.len();
475 result.facts_extracted = result.facts.len();
476 result
477}
478
479pub(crate) fn read_json_file(source: ImportSource, root: &Path, path: &Path) -> ImportResult {
480 let mut result = ImportResult {
481 sessions_found: 1,
482 ..ImportResult::default()
483 };
484 let mut touched = HashSet::new();
485 let mut seen = HashSet::new();
486 let gateway = crate::core::context_admission::carrier::gateway_snapshot();
487 let rules = Some(gateway.admission_policy());
488 let session = session_id(source, path, rules);
489 match read_transcript(root, path, &gateway) {
490 Err(refusal) => result.refuse(refusal),
491 Ok(content) => match serde_json::from_str::<Value>(&content) {
492 Ok(value) => process_admitted(
493 Origin::new(source, &session, rules),
494 &AdmittedValue::of_admitted_source(value),
495 &mut result,
496 &mut touched,
497 &mut seen,
498 ),
499 Err(error) => push_error(
500 &mut result,
501 format!("{}: invalid JSON: {error}", path.display()),
502 ),
503 },
504 }
505
506 result.files_touched = touched.len();
507 result.facts_extracted = result.facts.len();
508 result
509}
510
511#[cfg(test)]
515pub(crate) fn process_value(
516 source: ImportSource,
517 session: &str,
518 value: &Value,
519 result: &mut ImportResult,
520 touched: &mut HashSet<String>,
521 seen: &mut HashSet<(String, String)>,
522) {
523 if let Some(admitted) = admit_column(result, value.clone()) {
524 let origin = Origin::new(source, session, None);
525 process_admitted(origin, &admitted, result, touched, seen);
526 }
527}
528
529fn process_admitted(
533 origin: Origin<'_>,
534 value: &AdmittedValue,
535 result: &mut ImportResult,
536 touched: &mut HashSet<String>,
537 seen: &mut HashSet<(String, String)>,
538) {
539 staging::select(value, |found| {
542 staging::take_over(found, origin, result, touched, seen);
543 });
544}
545
546pub(crate) fn finish_result(result: &mut ImportResult, touched: &HashSet<String>) {
547 result.files_touched = touched.len();
548 result.facts_extracted = result.facts.len();
549}
550
551pub(crate) fn push_error(result: &mut ImportResult, error: String) {
552 if result.errors.len() < MAX_ERRORS {
553 result.errors.push(error);
554 }
555}
556
557pub(crate) fn session_id(source: ImportSource, path: &Path, rules: Rules<'_>) -> String {
558 let name = path
559 .file_name()
560 .and_then(|name| name.to_str())
561 .unwrap_or("unknown-session");
562 session_label(source, name, rules)
563}
564
565pub(crate) fn session_label(source: ImportSource, name: &str, rules: Rules<'_>) -> String {
571 let kept = crate::core::context_admission::carrier::admit_for_store_under(name, rules)
572 .is_ok_and(|admitted| admitted == name);
573 let name = if kept { name } else { "unnamed-session" };
574 format!("{}:{name}", source.as_str())
575}
576
577fn push_fact(
578 result: &mut ImportResult,
579 seen: &mut HashSet<(String, String)>,
580 Origin {
581 source,
582 session,
583 rules,
584 }: Origin<'_>,
585 category: &str,
586 value: &str,
587 confidence: f32,
588) {
589 let value = truncate(value);
590 if value.is_empty() || !seen.insert((category.to_owned(), value.clone())) {
591 return;
592 }
593 let admitted = crate::core::context_admission::carrier::admit_for_store_under(&value, rules);
600 let Ok(value) = admitted else {
601 result.facts_skipped += 1;
602 return;
603 };
604 if value.is_empty() {
605 return;
606 }
607
608 let now = Utc::now();
609 result.facts.push(KnowledgeFact {
610 anchors: Vec::new(),
611 review: None,
612 capture: Default::default(),
613 origin: crate::core::knowledge::FactOrigin::Unverified,
614 category: category.to_owned(),
615 key: String::new(),
616 value,
617 source_session: session.to_owned(),
618 confidence,
619 created_at: now,
620 last_confirmed: now,
621 retrieval_count: 0,
622 last_retrieved: None,
623 valid_from: None,
624 valid_until: None,
625 supersedes: None,
626 confirmation_count: 0,
627 feedback_up: 0,
628 feedback_down: 0,
629 last_feedback: None,
630 privacy: Default::default(),
631 sensitivity: Default::default(),
632 imported_from: Some(source.as_str().to_owned()),
633 archetype: Default::default(),
634 fidelity: None,
635 revision_count: 0,
636 status: None,
637 validity: None,
638 });
639}
640
641pub(crate) struct RepoFact {
644 pub category: &'static str,
645 pub key: String,
648 pub value: String,
649 pub confidence: f32,
650 pub validity: Option<crate::core::knowledge::FactValidity>,
651 pub valid_from: Option<chrono::DateTime<Utc>>,
652 pub anchors: Vec<crate::core::knowledge::anchors::CodeAnchor>,
654}
655
656pub(crate) fn push_repo_fact(
661 result: &mut ImportResult,
662 seen: &mut HashSet<(String, String)>,
663 source: ImportSource,
664 session: &str,
665 rules: Rules<'_>,
666 fact: RepoFact,
667) {
668 let value = truncate(&fact.value);
669 if value.is_empty() || !seen.insert((fact.category.to_owned(), value.clone())) {
670 return;
671 }
672 let Ok(value) = crate::core::context_admission::carrier::admit_for_store_under(&value, rules)
673 else {
674 result.facts_skipped += 1;
675 return;
676 };
677 if value.is_empty() {
678 return;
679 }
680 let now = Utc::now();
681 result.facts.push(KnowledgeFact {
682 origin: crate::core::knowledge::FactOrigin::Unverified,
683 category: fact.category.to_owned(),
684 key: fact.key,
685 value,
686 source_session: session.to_owned(),
687 confidence: fact.confidence,
688 created_at: now,
689 last_confirmed: now,
690 retrieval_count: 0,
691 last_retrieved: None,
692 valid_from: fact.valid_from,
693 valid_until: None,
694 supersedes: None,
695 confirmation_count: 0,
696 feedback_up: 0,
697 feedback_down: 0,
698 last_feedback: None,
699 privacy: Default::default(),
700 sensitivity: Default::default(),
701 imported_from: Some(source.as_str().to_owned()),
702 archetype: crate::core::knowledge::KnowledgeArchetype::infer_from_category(fact.category),
703 fidelity: None,
704 revision_count: 0,
705 status: None,
706 validity: fact.validity,
707 anchors: fact.anchors,
708 review: None,
709 capture: Default::default(),
710 });
711}
712
713fn collect_file_paths(value: &Value, out: &mut Vec<String>) {
714 match value {
715 Value::Array(values) => {
716 for value in values {
717 collect_file_paths(value, out);
718 }
719 }
720 Value::Object(map) => {
721 for (key, value) in map {
722 if matches!(
723 key.as_str(),
724 "file_path" | "filePath" | "path" | "filename" | "file_name"
725 ) {
726 if let Some(path) = value
727 .as_str()
728 .map(str::trim)
729 .filter(|path| !path.is_empty())
730 {
731 out.push(path.to_owned());
732 }
733 }
734 collect_file_paths(value, out);
735 }
736 }
737 _ => {}
738 }
739}
740
741fn collect_strings(value: &Value, out: &mut Vec<String>) {
742 match value {
743 Value::String(text) => out.push(text.to_owned()),
744 Value::Array(values) => {
745 for value in values {
746 collect_strings(value, out);
747 }
748 }
749 Value::Object(map) => {
750 for value in map.values() {
751 collect_strings(value, out);
752 }
753 }
754 _ => {}
755 }
756}
757
758fn contains_assistant_message(value: &Value) -> bool {
759 match value {
760 Value::Array(values) => values.iter().any(contains_assistant_message),
761 Value::Object(map) => {
762 map.get("role").and_then(Value::as_str) == Some("assistant")
763 || matches!(
764 map.get("type").and_then(Value::as_str),
765 Some("assistant" | "assistant_message")
766 )
767 || map.values().any(contains_assistant_message)
768 }
769 _ => false,
770 }
771}
772
773fn matching_snippets(text: &str, predicate: fn(&str) -> bool) -> Vec<String> {
774 text.split(['\n', '.', '!', '?'])
775 .map(str::trim)
776 .filter(|snippet| snippet.len() >= 4 && predicate(snippet))
777 .map(str::to_owned)
778 .collect()
779}
780
781fn is_decision(text: &str) -> bool {
782 let text = text.to_ascii_lowercase();
783 [
784 "decision",
785 "decided",
786 "we chose",
787 "i chose",
788 "chosen",
789 "will use",
790 "we'll use",
791 "we will use",
792 "going with",
793 "approach is",
794 ]
795 .iter()
796 .any(|marker| text.contains(marker))
797}
798
799fn is_error(text: &str) -> bool {
800 let text = text.to_ascii_lowercase();
801 [
802 "error",
803 "failed",
804 "failure",
805 "panic",
806 "unable to",
807 "cannot ",
808 "can't ",
809 ]
810 .iter()
811 .any(|marker| text.contains(marker))
812}
813
814fn truncate(value: &str) -> String {
815 value[..value.floor_char_boundary(MAX_FACT_VALUE_CHARS)]
821 .trim()
822 .to_owned()
823}
824
825#[cfg(test)]
826mod tests {
827 use super::*;
828
829 #[test]
830 fn truncates_multibyte_facts_without_panicking() {
831 let value = format!("We decided {}", "é".repeat(300));
832 let truncated = truncate(&value);
833 assert!(truncated.len() <= MAX_FACT_VALUE_CHARS);
834 assert!(truncated.is_char_boundary(truncated.len()));
835 }
836
837 #[test]
838 fn extracts_touches_decisions_and_errors() {
839 let value: Value = serde_json::json!({
840 "role": "assistant",
841 "content": "We decided to use a cache. Build failed because input is invalid.",
842 "tool_input": { "file_path": "rust/src/lib.rs" }
843 });
844 let mut result = ImportResult::default();
845 let mut touched = HashSet::new();
846 let mut seen = HashSet::new();
847
848 process_value(
849 ImportSource::Codex,
850 "codex:test",
851 &value,
852 &mut result,
853 &mut touched,
854 &mut seen,
855 );
856 finish_result(&mut result, &touched);
857
858 assert_eq!(result.files_touched, 1);
859 assert!(
860 result
861 .facts
862 .iter()
863 .any(|fact| fact.category == "imported-decision")
864 );
865 assert!(
866 result
867 .facts
868 .iter()
869 .any(|fact| fact.value.starts_with("Observed error:"))
870 );
871 }
872
873 const AWS_KEY: &str = concat!("AK", "IAIOSFODNN7EXAMPLE");
874
875 fn history() -> (tempfile::TempDir, PathBuf, PathBuf) {
878 let dir = tempfile::tempdir().expect("history dir");
879 let root = dir.path().canonicalize().expect("history root");
880 let sessions = root.join("project-a");
881 std::fs::create_dir_all(&sessions).expect("session dir");
882 (dir, root, sessions)
883 }
884
885 fn assistant_line(text: &str) -> String {
886 serde_json::json!({ "message": { "role": "assistant", "content": text } }).to_string()
887 }
888
889 fn decisions(result: &ImportResult) -> Vec<&str> {
890 result
891 .facts
892 .iter()
893 .filter(|fact| fact.category == "imported-decision")
894 .map(|fact| fact.value.as_str())
895 .collect()
896 }
897
898 #[test]
903 fn a_transcript_is_read_admitted_and_its_facts_are_admitted_where_they_are_built() {
904 let _data = crate::core::data_dir::isolated_data_dir();
905 let _community = crate::core::policy::runtime::TestPolicyOverride::set(None);
906 let (_dir, root, sessions) = history();
907 std::fs::write(
908 sessions.join("session.jsonl"),
909 format!(
910 "{}\n{}\n",
911 assistant_line("We decided to use JWT RS256 for the session tokens"),
912 assistant_line(&format!(
913 "We decided to deploy with the key {AWS_KEY} today"
914 )),
915 ),
916 )
917 .expect("transcript");
918
919 let result = claude_code::import_from_root(&root);
920 assert!(result.errors.is_empty(), "{:?}", result.errors);
921 assert_eq!(result.sessions_found, 1);
922 let decisions = decisions(&result);
923 assert!(
924 decisions.contains(&"We decided to use JWT RS256 for the session tokens"),
925 "a clean fact keeps its bytes: {decisions:?}"
926 );
927 assert!(
928 result
929 .facts
930 .iter()
931 .all(|fact| !fact.value.contains(AWS_KEY)),
932 "stored masked, or skipped — never raw"
933 );
934 assert_eq!(decisions.len() + result.facts_skipped, 2);
936 assert_eq!(result.facts_extracted, result.facts.len());
937 }
938
939 #[test]
942 fn a_restricted_value_is_skipped_and_counted() {
943 let _data = crate::core::data_dir::isolated_data_dir();
944 let policy = crate::core::policy::load(
945 "name = \"import-admission-test\"\nversion = \"1.0.0\"\ndescription = \"test\"\n\
946 [filters]\nclassification = \"block\"\nblocked_labels = [\"CONFIDENTIAL\"]\n",
947 )
948 .expect("policy");
949 let _policy = crate::core::policy::runtime::TestPolicyOverride::set(Some(policy));
950 let mut result = ImportResult::default();
951 let mut seen = HashSet::new();
952
953 for value in [
954 "We decided to use a cache",
955 "CONFIDENTIAL\nWe decided to ship roadmap-marker in May",
956 "CONFIDENTIAL\nWe decided to ship roadmap-marker in May",
958 ] {
959 push_fact(
960 &mut result,
961 &mut seen,
962 Origin::new(ImportSource::Codex, "codex:test", None),
963 "imported-decision",
964 value,
965 0.65,
966 );
967 }
968
969 assert_eq!(decisions(&result), vec!["We decided to use a cache"]);
970 assert_eq!(result.facts_skipped, 1);
971 assert!(result.errors.is_empty(), "nothing is reported about it");
972
973 let mut merged = ImportResult::default();
974 merged.merge(result);
975 assert_eq!(merged.facts_skipped, 1, "the count survives a merge");
976 }
977
978 #[cfg(unix)]
981 #[test]
982 fn a_transcript_outside_every_root_is_not_read() {
983 let _data = crate::core::data_dir::isolated_data_dir();
984 let _community = crate::core::policy::runtime::TestPolicyOverride::set(None);
985 let (_dir, root, sessions) = history();
986 let elsewhere = tempfile::tempdir().expect("outside dir");
987 let outside = elsewhere
988 .path()
989 .canonicalize()
990 .expect("outside root")
991 .join("outside.jsonl");
992 std::fs::write(
993 &outside,
994 format!(
995 "{}\n",
996 assistant_line("We decided to keep outside-marker secret")
997 ),
998 )
999 .expect("outside transcript");
1000 std::os::unix::fs::symlink(&outside, sessions.join("linked.jsonl")).expect("symlink");
1001
1002 let result = claude_code::import_from_root(&root);
1003 assert!(result.facts.is_empty(), "{:?}", decisions(&result));
1004 assert_eq!(result.facts_skipped, 0, "not read, so nothing was judged");
1005 assert_eq!(result.sources_refused, 1, "counted as refused");
1006 assert!(
1007 result.errors.is_empty(),
1008 "no path, no text: {:?}",
1009 result.errors
1010 );
1011
1012 let inside = sessions.join("inside.jsonl");
1015 std::fs::write(&inside, "{}\n").expect("inside transcript");
1016 assert_eq!(
1017 resolve_history_source(&root, &inside),
1018 Ok(HistorySource {
1019 root: root.clone(),
1020 path: inside,
1021 })
1022 );
1023 assert_eq!(
1024 resolve_history_source(&root, &outside),
1025 Err(SourceRefusal::OutsideRoot)
1026 );
1027 assert_eq!(
1028 resolve_history_source(&root, &sessions.join("linked.jsonl")),
1029 Err(SourceRefusal::OutsideRoot),
1030 "a link inside the root that points outside"
1031 );
1032 assert_eq!(
1033 resolve_history_source(&root, &root.join("missing.db")),
1034 Err(SourceRefusal::Unreadable)
1035 );
1036 assert!(
1037 !SourceRefusal::OutsideRoot
1038 .to_string()
1039 .contains("outside.jsonl")
1040 );
1041 }
1042
1043 #[test]
1048 fn history_files_inside_their_named_roots_are_read() {
1049 let _data = crate::core::data_dir::isolated_data_dir();
1050 let _community = crate::core::policy::runtime::TestPolicyOverride::set(None);
1051 let home = tempfile::tempdir().expect("home");
1052 let home = home.path().canonicalize().expect("home root");
1053
1054 let claude_root = home.join(".claude").join("projects");
1055 let claude_sessions = claude_root.join("-Users-dev-project");
1056 std::fs::create_dir_all(&claude_sessions).expect("claude dir");
1057 std::fs::write(
1058 claude_sessions.join("0b1c.jsonl"),
1059 format!(
1060 "{}\n",
1061 assistant_line("We decided to keep the claude marker")
1062 ),
1063 )
1064 .expect("claude transcript");
1065 let claude = claude_code::import_from_root(&claude_root);
1066 assert_eq!((claude.sources_refused, claude.sources_skipped), (0, 0));
1067 assert!(claude.errors.is_empty(), "{:?}", claude.errors);
1068 assert_eq!(
1069 decisions(&claude),
1070 vec!["We decided to keep the claude marker"]
1071 );
1072
1073 let codex_root = home.join(".codex");
1074 let codex_sessions = codex_root.join("sessions").join("2026");
1075 std::fs::create_dir_all(&codex_sessions).expect("codex dir");
1076 std::fs::write(
1077 codex_sessions.join("rollout-1.jsonl"),
1078 format!(
1079 "{}\n",
1080 assistant_line("We decided to keep the codex marker")
1081 ),
1082 )
1083 .expect("codex transcript");
1084 let codex = codex::import_from_root(&codex_root);
1085 assert_eq!((codex.sources_refused, codex.sources_skipped), (0, 0));
1086 assert!(codex.errors.is_empty(), "{:?}", codex.errors);
1087 assert_eq!(
1088 decisions(&codex),
1089 vec!["We decided to keep the codex marker"]
1090 );
1091 }
1092
1093 #[test]
1098 fn a_transcript_above_the_read_cap_is_skipped_and_counted() {
1099 let _data = crate::core::data_dir::isolated_data_dir();
1100 let _community = crate::core::policy::runtime::TestPolicyOverride::set(None);
1101 let (_dir, root, sessions) = history();
1102 let first = assistant_line("We decided to keep the early marker");
1103 let filler = assistant_line(&"x".repeat(4096));
1104 std::fs::write(sessions.join("large.jsonl"), format!("{first}\n{filler}\n"))
1105 .expect("large transcript");
1106
1107 crate::test_env::set_var("LCTX_MAX_READ_BYTES", "1024");
1108 let result = claude_code::import_from_root(&root);
1109 let rules = crate::core::context_admission::carrier::gateway_snapshot();
1110 let reason = resolve_history_source(&root, &sessions.join("large.jsonl"))
1111 .and_then(|source| pinned(&source, || bounded::admit(&source, &rules).map(|_| ())));
1112 crate::test_env::remove_var("LCTX_MAX_READ_BYTES");
1113
1114 assert_eq!(reason, Err(SourceRefusal::RecordTooLarge));
1115 assert_eq!(result.sources_skipped, 1);
1116 assert_eq!(result.sources_refused, 0);
1117 assert!(result.facts.is_empty(), "not even the part below the cap");
1118 assert!(result.errors.is_empty(), "{:?}", result.errors);
1119 }
1120
1121 #[test]
1124 fn a_session_label_is_kept_only_if_it_is_admitted_unchanged() {
1125 let _data = crate::core::data_dir::isolated_data_dir();
1126 let _community = crate::core::policy::runtime::TestPolicyOverride::set(None);
1127 assert_eq!(
1128 session_label(ImportSource::OpenCode, "ses_01HZX", None),
1129 "opencode:ses_01HZX"
1130 );
1131 let label = session_label(
1132 ImportSource::Codex,
1133 &format!("rollout {AWS_KEY}.jsonl"),
1134 None,
1135 );
1136 assert_eq!(label, "codex:unnamed-session");
1137 }
1138
1139 const BLOCK_CONFIDENTIAL: &str = "name = \"import-original-test\"\nversion = \"1.0.0\"\n\
1140 description = \"test\"\n[filters]\nclassification = \"block\"\n\
1141 blocked_labels = [\"CONFIDENTIAL\"]\n";
1142
1143 fn block_confidential() -> crate::core::policy::runtime::TestPolicyOverride {
1144 let policy = crate::core::policy::load(BLOCK_CONFIDENTIAL).expect("policy");
1145 crate::core::policy::runtime::TestPolicyOverride::set(Some(policy))
1146 }
1147
1148 fn codex_database(path: &Path, rows: &[&str]) {
1150 let db = rusqlite::Connection::open(path).expect("database");
1151 db.execute_batch("CREATE TABLE history (id INTEGER PRIMARY KEY, body TEXT NOT NULL);")
1152 .expect("schema");
1153 for row in rows {
1154 db.execute("INSERT INTO history (body) VALUES (?1)", [row])
1155 .expect("row");
1156 }
1157 }
1158
1159 #[test]
1163 fn a_marker_anywhere_in_an_original_value_refuses_the_whole_value() {
1164 let _data = crate::core::data_dir::isolated_data_dir();
1165 let _policy = block_confidential();
1166 let marked = serde_json::json!({
1167 "role": "assistant",
1168 "content": "We decided to keep the marked sentence",
1169 "banner": "CONFIDENTIAL",
1170 });
1171 assert!(AdmittedValue::admit(marked.clone(), None).is_err());
1172
1173 let mut result = ImportResult::default();
1174 assert!(admit_column(&mut result, marked).is_none());
1175 assert_eq!(result.facts_skipped, 1, "counted once per original value");
1176
1177 let clean = serde_json::json!({
1178 "role": "assistant",
1179 "content": "We decided to use a bounded cache",
1180 });
1181 let admitted = AdmittedValue::admit(clean.clone(), None).expect("a clean value");
1182 assert_eq!(admitted.value(), &clean, "a clean value keeps its bytes");
1183 let derived = admitted.derive(serde_json::json!({ "role": "assistant" }));
1184 assert_eq!(derived.value()["role"], "assistant");
1185 }
1186
1187 #[test]
1189 fn a_masked_original_is_only_ever_seen_masked() {
1190 let _data = crate::core::data_dir::isolated_data_dir();
1191 let _community = crate::core::policy::runtime::TestPolicyOverride::set(None);
1192 let keyed = serde_json::json!({
1193 "role": "assistant",
1194 "content": format!("We decided to deploy today. The key is {AWS_KEY}"),
1195 });
1196 match AdmittedValue::admit(keyed, None) {
1197 Ok(admitted) => {
1198 let text = admitted.value().to_string();
1199 assert!(!text.contains(AWS_KEY), "{text}");
1200 assert!(text.contains("We decided to deploy today"), "{text}");
1201 }
1202 Err(refusal) => assert!(
1204 matches!(refusal, ValueRefusal::NotStorable | ValueRefusal::Withheld),
1205 "{refusal:?}"
1206 ),
1207 }
1208 }
1209
1210 #[test]
1213 fn a_marked_codex_column_value_yields_no_fact() {
1214 let _data = crate::core::data_dir::isolated_data_dir();
1215 let (_dir, root, _sessions) = history();
1216 codex_database(
1217 &root.join("history.sqlite"),
1218 &[
1219 r#"{"role":"assistant","content":"We decided to keep the marked sentence","banner":"CONFIDENTIAL"}"#,
1220 r#"{"role":"assistant","content":"We decided to use a bounded cache"}"#,
1221 ],
1222 );
1223
1224 let _policy = block_confidential();
1225 let result = codex::import_from_root(&root);
1226 assert_eq!(
1227 decisions(&result),
1228 vec!["We decided to use a bounded cache"]
1229 );
1230 assert_eq!(result.facts_skipped, 1);
1231 assert_eq!((result.sources_refused, result.sources_skipped), (0, 0));
1232 assert!(
1233 result
1234 .facts
1235 .iter()
1236 .all(|fact| !fact.value.contains("marked sentence"))
1237 );
1238
1239 let broken = rusqlite::Connection::open(root.join("broken.sqlite")).expect("database");
1244 broken
1245 .execute_batch(
1246 "CREATE TABLE history (id INTEGER PRIMARY KEY, body TEXT NOT NULL); \
1247 INSERT INTO history (body) VALUES \
1248 ('{\"role\":\"assistant\",\"content\":\"We decided to keep the early row\"}'), \
1249 ('not json'); \
1250 ALTER TABLE history ADD COLUMN kind TEXT \
1251 GENERATED ALWAYS AS (json_extract(body, '$.role')) VIRTUAL;",
1252 )
1253 .expect("fixture");
1254 drop(broken);
1255 let again = codex::import_from_root(&root);
1256 assert_eq!(decisions(&again), decisions(&result));
1257 assert_eq!((again.sources_skipped, again.sources_refused), (1, 0));
1258 assert_eq!(
1259 (again.sessions_found, again.facts_skipped),
1260 (result.sessions_found, 1)
1261 );
1262 assert_eq!(again.errors.len(), 1, "{:?}", again.errors);
1263 assert!(again.errors[0].ends_with(": history database could not be read; skipped"));
1264 }
1265
1266 #[test]
1269 fn a_keyed_codex_column_value_never_yields_the_key() {
1270 let _data = crate::core::data_dir::isolated_data_dir();
1271 let _community = crate::core::policy::runtime::TestPolicyOverride::set(None);
1272 let (_dir, root, _sessions) = history();
1273 let row = serde_json::json!({
1274 "role": "assistant",
1275 "content": format!("We decided to deploy today. The key is {AWS_KEY}"),
1276 })
1277 .to_string();
1278 codex_database(&root.join("history.sqlite"), &[&row]);
1279
1280 let result = codex::import_from_root(&root);
1281 assert_eq!(result.sources_refused, 0);
1282 assert!(
1283 result
1284 .facts
1285 .iter()
1286 .all(|fact| !fact.value.contains(AWS_KEY))
1287 );
1288 if result.facts_skipped == 0 {
1289 assert!(
1290 decisions(&result).contains(&"We decided to deploy today"),
1291 "the clean sentence of the masked value is imported: {:?}",
1292 decisions(&result)
1293 );
1294 }
1295 }
1296
1297 #[test]
1301 fn a_marked_transcript_yields_no_fact() {
1302 let _data = crate::core::data_dir::isolated_data_dir();
1303 let (_dir, root, sessions) = history();
1304 std::fs::write(
1305 sessions.join("session.jsonl"),
1306 format!(
1307 "CONFIDENTIAL\n{}\n",
1308 assistant_line("We decided to keep the marked sentence")
1309 ),
1310 )
1311 .expect("transcript");
1312
1313 let _policy = block_confidential();
1314 let result = claude_code::import_from_root(&root);
1315 assert!(result.facts.is_empty(), "{:?}", decisions(&result));
1316 assert_eq!(result.sources_refused, 1);
1317 assert_eq!(result.facts_skipped, 0, "the file was refused as a whole");
1318 assert!(result.errors.is_empty(), "{:?}", result.errors);
1319 }
1320
1321 #[test]
1325 fn a_database_at_a_refused_path_is_not_opened() {
1326 let _data = crate::core::data_dir::isolated_data_dir();
1327 let _community = crate::core::policy::runtime::TestPolicyOverride::set(None);
1328 let (_dir, root, _sessions) = history();
1329 let secret_dir = root.join(".aws");
1330 std::fs::create_dir_all(&secret_dir).expect("secret-like dir");
1331 let database = secret_dir.join("history.sqlite");
1332 codex_database(
1333 &database,
1334 &[r#"{"role":"assistant","content":"We decided to use a bounded cache"}"#],
1335 );
1336
1337 crate::test_env::set_var("LEAN_CTX_IO_BOUNDARY_MODE", "enforce");
1338 let decision = admit_history_database(&root, &database);
1339 let result = codex::import_from_root(&root);
1340 crate::test_env::remove_var("LEAN_CTX_IO_BOUNDARY_MODE");
1341
1342 assert_eq!(decision, Err(SourceRefusal::Withheld));
1343 assert_eq!(result.sources_refused, 1);
1344 assert_eq!(result.facts_skipped, 0);
1345 assert!(result.facts.is_empty(), "{:?}", decisions(&result));
1346
1347 crate::test_env::set_var("LEAN_CTX_IO_BOUNDARY_MODE", "warn");
1351 let warned = admit_history_database(&root, &database);
1352 crate::test_env::remove_var("LEAN_CTX_IO_BOUNDARY_MODE");
1353 assert!(warned.is_ok());
1354 }
1355
1356 #[test]
1365 fn a_transcript_above_the_read_cap_is_imported_in_admitted_units() {
1366 let _data = crate::core::data_dir::isolated_data_dir();
1367 let policy = crate::core::policy::load(&format!("{BLOCK_CONFIDENTIAL}pii = \"block\"\n"))
1368 .expect("policy");
1369 let _policy = crate::core::policy::runtime::TestPolicyOverride::set(Some(policy));
1370 let _cap = crate::test_env::EnvVarGuard::set("LCTX_MAX_READ_BYTES", "1024");
1371 let (_dir, root, sessions) = history();
1372 let expected: Vec<String> = ('a'..='c')
1374 .flat_map(|x| ('a'..='t').map(move |y| (x, y)))
1375 .map(|(x, y)| format!("We decided to keep the unit marker é {x}{y}"))
1376 .collect();
1377 let records: Vec<String> = expected.iter().map(|text| assistant_line(text)).collect();
1378 let write = |records: &[String], end: &str| {
1379 std::fs::write(sessions.join("long.jsonl"), records.join("\n") + "\n" + end)
1380 .expect("transcript");
1381 };
1382 let width = records[0].len();
1383 let per_unit = bounded::unit_bytes() / (width + 1);
1384 assert!(records.len() > 3 * per_unit, "several units");
1385
1386 write(&records, "");
1387 let result = claude_code::import_from_root(&root);
1388 assert_eq!((result.sources_skipped, result.sources_refused), (0, 0));
1389 assert!(result.errors.is_empty(), "{:?}", result.errors);
1390 assert_eq!(decisions(&result), expected);
1391
1392 write(&records, "CONFIDENTIAL\n");
1393 let result = claude_code::import_from_root(&root);
1394 assert!(result.facts.is_empty(), "{:?}", decisions(&result));
1395 assert_eq!((result.sources_skipped, result.sources_refused), (0, 1));
1396
1397 use crate::core::context_admission::cross_line::pattern_is_single_line;
1398 assert!(pattern_is_single_line(r"EMP-\d{4}") && !pattern_is_single_line(r"left\s+right"));
1399
1400 write(&records, "");
1405 std::fs::write(sessions.join("next.jsonl"), format!("{}\n", records[0])).expect("next");
1406 bounded::FAIL_BEFORE_UNIT.with(|fault| fault.set(Some(1)));
1407 let result = claude_code::import_from_root(&root);
1408 bounded::FAIL_BEFORE_UNIT.with(|fault| fault.set(None));
1409 assert_eq!(decisions(&result), vec![expected[0].as_str()]);
1410 assert_eq!(
1411 (
1412 result.sources_skipped,
1413 result.sources_refused,
1414 result.facts_skipped
1415 ),
1416 (1, 0, 0)
1417 );
1418 assert_eq!((result.facts_extracted, result.errors.len()), (1, 0));
1419 }
1420
1421 #[cfg(unix)]
1426 #[test]
1427 fn a_database_exchanged_after_verification_is_refused() {
1428 let _data = crate::core::data_dir::isolated_data_dir();
1429 let _community = crate::core::policy::runtime::TestPolicyOverride::set(None);
1430 let (_dir, root, sessions) = history();
1431 let database = root.join("history.sqlite");
1432 let other = sessions.join("other.sqlite");
1433 let row = r#"{"role":"assistant","content":"We decided to keep the verified marker"}"#;
1434 codex_database(&database, &[row]);
1435 codex_database(&other, &[row]);
1436
1437 let verified = database::verify_history_database(&root, &database).expect("verified");
1438 std::fs::rename(&other, &database).expect("rename over");
1439 assert_eq!(verified.into_snapshot().err(), Some(SourceRefusal::Changed));
1440
1441 let verified = database::verify_history_database(&root, &database).expect("verified");
1442 std::fs::rename(&database, &other).expect("move away");
1443 std::os::unix::fs::symlink(&other, &database).expect("link in its place");
1444 assert_eq!(verified.into_snapshot().err(), Some(SourceRefusal::Changed));
1445 }
1446}