1use anyhow::{Result, anyhow};
12use std::borrow::Cow;
13use std::fmt;
14use std::io::{self, BufRead, Read};
15use std::path::{Component, Path, PathBuf};
16
17pub const ALLOWED_AGENTS: &[&str] = &[
19 "claude",
20 "codex",
21 "gemini",
22 "junie",
23 "codescribe",
24 "operator-md",
25];
26
27const AICX_ALLOW_TMP_ENV: &str = "AICX_ALLOW_TMP";
28
29pub const MAX_VALIDATED_BYTES: usize = 8 * 1024 * 1024;
30
31pub const MAX_STATE_JSON_BYTES: usize = 128 * 1024 * 1024;
39
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub enum SanitizeError {
42 FileTooLarge {
43 path: PathBuf,
44 max_bytes: usize,
45 actual_bytes: u64,
46 },
47 StateFileTooLarge {
48 path: PathBuf,
49 max_bytes: usize,
50 actual_bytes: u64,
51 },
52}
53
54impl fmt::Display for SanitizeError {
55 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
56 match self {
57 SanitizeError::FileTooLarge {
58 path,
59 max_bytes,
60 actual_bytes,
61 } => write!(
62 f,
63 "File '{}' exceeds validated read cap: {} bytes > {} bytes",
64 path.display(),
65 actual_bytes,
66 max_bytes
67 ),
68 SanitizeError::StateFileTooLarge {
69 path,
70 max_bytes,
71 actual_bytes,
72 } => write!(
73 f,
74 "State file '{}' is too large: {} bytes > {} bytes. \
75 This is not generic JSON corruption — the file exceeds the dedicated state cap. \
76 Investigate runaway `seen_hashes` growth or restore from `.bak` before retrying.",
77 path.display(),
78 actual_bytes,
79 max_bytes
80 ),
81 }
82 }
83}
84
85impl std::error::Error for SanitizeError {}
86
87#[derive(Debug, Clone, PartialEq, Eq)]
88pub enum ContentSanitizationWarning {
89 NullByteStripped(usize),
90 BidiOverride(char, usize),
91 ZeroWidth(char, usize),
92}
93
94#[derive(Debug, Clone, PartialEq, Eq)]
95pub struct SanitizedContent<'a> {
96 pub text: Cow<'a, str>,
97 pub warnings: Vec<ContentSanitizationWarning>,
98}
99
100#[derive(Debug, Clone, PartialEq, Eq)]
101pub struct CappedLine {
102 pub line: String,
103 pub exceeded: bool,
104}
105
106fn contains_traversal(path: &str) -> bool {
119 if path.contains('\0') || path.contains('\n') || path.contains('\r') {
120 return true;
121 }
122 if path.split(['/', '\\']).any(|segment| segment == "..") {
131 return true;
132 }
133 Path::new(path)
134 .components()
135 .any(|c| matches!(c, Component::ParentDir))
136}
137
138fn current_user_allowed_bases() -> Result<Vec<PathBuf>> {
139 let mut bases = Vec::new();
140 for base in [dirs::home_dir(), dirs::cache_dir(), dirs::data_dir()]
141 .into_iter()
142 .flatten()
143 {
144 if !bases.iter().any(|existing| existing == &base) {
145 bases.push(base);
146 }
147 }
148
149 if bases.is_empty() {
150 return Err(anyhow!(
151 "Cannot determine current user allowed base directories"
152 ));
153 }
154
155 Ok(bases)
156}
157
158fn canonicalize_existing(path: &Path) -> Result<PathBuf> {
160 path.canonicalize()
161 .map_err(|e| anyhow!("Cannot canonicalize path '{}': {}", path.display(), e))
162}
163
164fn temp_allowlist_enabled() -> bool {
168 temp_allowlist_enabled_for_runtime(cfg!(test), running_under_cargo_test_harness())
169}
170
171fn temp_allowlist_enabled_for_runtime(is_test_build: bool, is_cargo_test_harness: bool) -> bool {
172 is_test_build
173 || is_cargo_test_harness
174 || std::env::var(AICX_ALLOW_TMP_ENV).is_ok_and(|value| value == "1")
175}
176
177fn running_under_cargo_test_harness() -> bool {
178 std::env::current_exe()
179 .ok()
180 .is_some_and(|exe| is_cargo_test_exe_path(&exe))
181}
182
183fn is_cargo_test_exe_path(path: &Path) -> bool {
184 let has_deps_component = path.components().any(|component| {
185 matches!(
186 component,
187 Component::Normal(text) if text == std::ffi::OsStr::new("deps")
188 )
189 });
190 if !has_deps_component {
191 return false;
192 }
193
194 path.file_stem()
195 .and_then(|stem| stem.to_str())
196 .and_then(|stem| stem.rsplit_once('-'))
197 .is_some_and(|(_, suffix)| {
198 suffix.len() >= 8 && suffix.chars().all(|ch| ch.is_ascii_hexdigit())
199 })
200}
201
202fn is_temp_allowlist_path(path: &Path) -> bool {
203 if path.starts_with("/tmp")
204 || path.starts_with("/var/folders")
205 || path.starts_with("/private/tmp")
206 || path.starts_with("/private/var/folders")
207 {
208 return true;
209 }
210 let mut components = path.components().peekable();
216 while let Some(component) = components.next() {
217 if component.as_os_str() == std::ffi::OsStr::new("target")
218 && let Some(next) = components.peek()
219 && next.as_os_str() == std::ffi::OsStr::new("test-tmp")
220 {
221 return true;
222 }
223 }
224 false
225}
226
227fn strip_verbatim_prefix(path: &Path) -> PathBuf {
233 let text = path.to_string_lossy();
234 if let Some(rest) = text.strip_prefix(r"\\?\UNC\") {
235 return PathBuf::from(format!(r"\\{rest}"));
236 }
237 if let Some(rest) = text.strip_prefix(r"\\?\") {
238 return PathBuf::from(rest);
239 }
240 path.to_path_buf()
241}
242
243fn is_under_allowed_base(path: &Path) -> Result<bool> {
245 let path = strip_verbatim_prefix(path);
250 for base in current_user_allowed_bases()? {
251 let base = base.canonicalize().unwrap_or(base);
252 let base = strip_verbatim_prefix(&base);
253 if path.starts_with(&base) {
254 return Ok(true);
255 }
256 }
257
258 if is_temp_allowlist_path(&path) {
259 return Ok(temp_allowlist_enabled());
260 }
261
262 Ok(false)
263}
264
265pub fn validate_read_path(path: &Path) -> Result<PathBuf> {
273 let path_str = path.to_string_lossy();
274 if contains_traversal(&path_str) {
275 return Err(anyhow!(
276 "Path contains invalid traversal sequence: {}",
277 path_str
278 ));
279 }
280
281 if !path.exists() {
282 return Err(anyhow!("Path does not exist: {}", path.display()));
283 }
284
285 let canonical = canonicalize_existing(path)?;
286
287 if !is_under_allowed_base(&canonical)? {
288 return Err(anyhow!(
289 "Cannot read from path outside allowed directories: {}",
290 canonical.display()
291 ));
292 }
293
294 Ok(canonical)
295}
296
297pub fn validate_write_path(path: &Path) -> Result<PathBuf> {
301 let path_str = path.to_string_lossy();
302 if contains_traversal(&path_str) {
303 return Err(anyhow!(
304 "Path contains invalid traversal sequence: {}",
305 path_str
306 ));
307 }
308
309 if path.exists() {
310 let canonical = canonicalize_existing(path)?;
311 if !is_under_allowed_base(&canonical)? {
312 return Err(anyhow!(
313 "Cannot write to path outside allowed directories: {}",
314 canonical.display()
315 ));
316 }
317 return Ok(canonical);
318 }
319
320 let candidate = if path.is_absolute() {
322 path.to_path_buf()
323 } else {
324 std::env::current_dir()
325 .map_err(|e| anyhow!("Cannot determine current directory: {}", e))?
326 .join(path)
327 };
328
329 let mut ancestor = Some(candidate.as_path());
330 let mut existing_ancestor = None;
331 while let Some(current) = ancestor {
332 if current.exists() {
333 existing_ancestor = Some(canonicalize_existing(current)?);
334 break;
335 }
336 ancestor = current.parent();
337 }
338
339 let canonical_base = existing_ancestor.ok_or_else(|| {
340 anyhow!(
341 "Cannot validate write path '{}': no existing ancestor found",
342 path.display()
343 )
344 })?;
345
346 if !is_under_allowed_base(&canonical_base)? {
347 return Err(anyhow!(
348 "Path '{}' would be created outside allowed directories",
349 path.display()
350 ));
351 }
352
353 Ok(path.to_path_buf())
354}
355
356pub fn validate_dir_path(path: &Path) -> Result<PathBuf> {
360 let validated = validate_read_path(path)?;
361 if !validated.is_dir() {
362 return Err(anyhow!("Path is not a directory: {}", validated.display()));
363 }
364 Ok(validated)
365}
366
367pub fn open_file_validated(path: &Path) -> Result<std::fs::File> {
369 let validated = validate_read_path(path)?;
370 std::fs::OpenOptions::new()
371 .read(true)
372 .open(&validated)
373 .map_err(|e| anyhow!("Failed to open '{}': {}", validated.display(), e))
374}
375
376pub fn create_file_validated(path: &Path) -> Result<std::fs::File> {
378 let validated = validate_write_path(path)?;
379 std::fs::OpenOptions::new()
380 .create(true)
381 .truncate(true)
382 .write(true)
383 .open(&validated)
384 .map_err(|e| anyhow!("Failed to create '{}': {}", validated.display(), e))
385}
386
387pub fn read_to_string_validated(path: &Path) -> Result<String> {
389 read_to_string_with_cap(path, MAX_VALIDATED_BYTES, false)
390}
391
392pub fn read_state_json_validated(path: &Path) -> Result<String> {
402 read_to_string_with_cap(path, MAX_STATE_JSON_BYTES, true)
403}
404
405fn read_to_string_with_cap(path: &Path, max_bytes: usize, is_state: bool) -> Result<String> {
406 let validated = validate_read_path(path)?;
407 let file = std::fs::OpenOptions::new()
408 .read(true)
409 .open(&validated)
410 .map_err(|e| anyhow!("Failed to open '{}': {}", validated.display(), e))?;
411 let metadata = file
412 .metadata()
413 .map_err(|e| anyhow!("Failed to stat '{}': {}", validated.display(), e))?;
414 if metadata.len() > max_bytes as u64 {
415 return Err(too_large_error(validated, max_bytes, metadata.len(), is_state).into());
416 }
417
418 let mut reader = file.take(max_bytes.saturating_add(1) as u64);
419 let mut bytes = Vec::new();
420 reader
421 .read_to_end(&mut bytes)
422 .map_err(|e| anyhow!("Failed to read '{}': {}", validated.display(), e))?;
423 if bytes.len() > max_bytes {
424 return Err(too_large_error(validated, max_bytes, bytes.len() as u64, is_state).into());
425 }
426 String::from_utf8(bytes).map_err(|e| anyhow!("Failed to read '{}': {}", validated.display(), e))
427}
428
429fn too_large_error(
430 path: PathBuf,
431 max_bytes: usize,
432 actual_bytes: u64,
433 is_state: bool,
434) -> SanitizeError {
435 if is_state {
436 SanitizeError::StateFileTooLarge {
437 path,
438 max_bytes,
439 actual_bytes,
440 }
441 } else {
442 SanitizeError::FileTooLarge {
443 path,
444 max_bytes,
445 actual_bytes,
446 }
447 }
448}
449
450pub fn read_dir_validated(path: &Path) -> Result<std::fs::ReadDir> {
452 let validated = validate_dir_path(path)?;
453 std::fs::read_dir(&validated)
459 .map_err(|e| anyhow!("Failed to read dir '{}': {}", validated.display(), e))
460}
461
462pub fn read_line_capped<R: BufRead>(
463 reader: &mut R,
464 max_bytes: usize,
465) -> io::Result<Option<CappedLine>> {
466 let mut buf = Vec::new();
467 let read = {
468 let mut limited = reader.take(max_bytes.saturating_add(1) as u64);
469 limited.read_until(b'\n', &mut buf)?
470 };
471 if read == 0 {
472 return Ok(None);
473 }
474
475 let exceeded = buf.len() > max_bytes;
476 if exceeded {
477 let ended_at_newline = buf.last().copied() == Some(b'\n');
478 buf.truncate(max_bytes);
479 while let Some(&last) = buf.last() {
483 if (last & 0xC0) == 0x80 {
484 buf.pop();
485 } else if last >= 0xC0 {
486 buf.pop();
489 break;
490 } else {
491 break;
492 }
493 }
494 if !ended_at_newline {
495 drain_until_newline(reader)?;
496 }
497 }
498
499 let line =
500 String::from_utf8(buf).map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?;
501 Ok(Some(CappedLine { line, exceeded }))
502}
503
504fn drain_until_newline<R: BufRead>(reader: &mut R) -> io::Result<()> {
505 loop {
506 let available = reader.fill_buf()?;
507 if available.is_empty() {
508 return Ok(());
509 }
510 let consume = available
511 .iter()
512 .position(|byte| *byte == b'\n')
513 .map_or(available.len(), |idx| idx + 1);
514 let ended_at_newline = available.get(consume.saturating_sub(1)) == Some(&b'\n');
515 reader.consume(consume);
516 if ended_at_newline {
517 return Ok(());
518 }
519 }
520}
521
522pub fn safe_agent_name(name: &str) -> Result<&str> {
531 if ALLOWED_AGENTS.contains(&name) {
532 Ok(name)
533 } else {
534 Err(anyhow!(
535 "Unknown agent: {:?}. Allowed: {}",
536 name,
537 ALLOWED_AGENTS.join(", ")
538 ))
539 }
540}
541
542pub fn safe_project_name(name: &str) -> Result<&str> {
547 if name.is_empty() {
548 return Err(anyhow!("Project name cannot be empty"));
549 }
550 if contains_traversal(name) || name.contains('/') || name.contains('\\') {
551 return Err(anyhow!("Invalid project name: {:?}", name));
552 }
553 Ok(name)
554}
555
556pub fn sanitize_chunk_content(text: &str) -> SanitizedContent<'_> {
561 let (text, warnings) = sanitize_message_content(text);
562 SanitizedContent { text, warnings }
563}
564
565fn sanitize_message_content(input: &str) -> (Cow<'_, str>, Vec<ContentSanitizationWarning>) {
566 let mut output: Option<String> = None;
567 let mut warnings = Vec::new();
568 let mut chars = input.char_indices().peekable();
569
570 while let Some((offset, ch)) = chars.next() {
571 match ch {
572 '\0' => {
573 warnings.push(ContentSanitizationWarning::NullByteStripped(offset));
574 ensure_output(&mut output, input, offset);
575 }
576 '\r' => {
577 ensure_output(&mut output, input, offset);
578 output.as_mut().expect("output initialized").push('\n');
579 if chars.peek().is_some_and(|(_, next)| *next == '\n') {
580 chars.next();
581 }
582 }
583 _ => {
584 if is_bidi_override(ch) {
585 warnings.push(ContentSanitizationWarning::BidiOverride(ch, offset));
586 } else if is_zero_width(ch) {
587 warnings.push(ContentSanitizationWarning::ZeroWidth(ch, offset));
588 }
589
590 if let Some(out) = output.as_mut() {
591 out.push(ch);
592 }
593 }
594 }
595 }
596
597 match output {
598 Some(output) => (Cow::Owned(output), warnings),
599 None => (Cow::Borrowed(input), warnings),
600 }
601}
602
603fn ensure_output(output: &mut Option<String>, input: &str, offset: usize) {
604 if output.is_none() {
605 let mut owned = String::with_capacity(input.len());
606 owned.push_str(&input[..offset]);
607 *output = Some(owned);
608 }
609}
610
611fn is_bidi_override(ch: char) -> bool {
612 matches!(
613 ch,
614 '\u{202A}'
615 | '\u{202B}'
616 | '\u{202C}'
617 | '\u{202D}'
618 | '\u{202E}'
619 | '\u{2066}'
620 | '\u{2067}'
621 | '\u{2068}'
622 | '\u{2069}'
623 )
624}
625
626fn is_zero_width(ch: char) -> bool {
627 matches!(ch, '\u{200B}' | '\u{200C}' | '\u{200D}' | '\u{FEFF}')
628}
629
630#[cfg(test)]
635mod tests {
636 use super::*;
637 use std::fs;
638 use std::sync::{Mutex, MutexGuard};
639
640 static ENV_MUTEX: Mutex<()> = Mutex::new(());
641
642 struct EnvVarGuard {
643 key: &'static str,
644 previous: Option<std::ffi::OsString>,
645 _guard: MutexGuard<'static, ()>,
646 }
647
648 impl EnvVarGuard {
649 fn set(key: &'static str, value: Option<&str>) -> Self {
650 let guard = ENV_MUTEX.lock().unwrap();
651 let previous = std::env::var_os(key);
652 unsafe {
655 match value {
656 Some(value) => std::env::set_var(key, value),
657 None => std::env::remove_var(key),
658 }
659 }
660 Self {
661 key,
662 previous,
663 _guard: guard,
664 }
665 }
666 }
667
668 impl Drop for EnvVarGuard {
669 fn drop(&mut self) {
670 unsafe {
673 match &self.previous {
674 Some(previous) => std::env::set_var(self.key, previous),
675 None => std::env::remove_var(self.key),
676 }
677 }
678 }
679 }
680
681 #[test]
682 fn test_contains_traversal() {
683 assert!(contains_traversal("../etc/passwd"));
684 assert!(contains_traversal("foo/../bar"));
685 assert!(contains_traversal("path\0with\0nulls"));
686 assert!(contains_traversal("line\nbreak"));
687 assert!(!contains_traversal("/normal/path"));
688 assert!(!contains_traversal("simple_name"));
689 assert!(!contains_traversal("./relative/path"));
690 }
691
692 #[test]
693 fn test_contains_traversal_does_not_flag_three_dot_folder() {
694 assert!(!contains_traversal("..."));
698 assert!(!contains_traversal("/Users/foo/.aicx/store/..."));
699 assert!(!contains_traversal("foo/.../bar"));
700 }
701
702 #[test]
703 fn test_contains_traversal_does_not_flag_dot_dot_inside_name() {
704 assert!(!contains_traversal("foo..bar"));
707 assert!(!contains_traversal("a..b/c"));
708 assert!(!contains_traversal("normal..text"));
709 assert!(!contains_traversal("/srv/a..b/c"));
710 }
711
712 #[test]
713 fn test_contains_traversal_flags_backslash_and_verbatim_segments() {
714 assert!(contains_traversal(r"foo\..\bar"));
721 assert!(contains_traversal(r"\\?\C:\tmp\nested\..\.aicxignore"));
722 assert!(!contains_traversal(r"\\?\C:\tmp\store\..."));
724 assert!(!contains_traversal(r"C:\srv\a..b\c"));
725 }
726
727 #[test]
728 fn test_contains_traversal_carriage_return() {
729 assert!(contains_traversal("path\rwith\rcr"));
730 }
731
732 #[test]
733 fn test_tmp_allowlist_hybrid_policy() {
734 {
735 let _env = EnvVarGuard::set(AICX_ALLOW_TMP_ENV, None);
736 assert!(temp_allowlist_enabled_for_runtime(true, false));
737 assert!(temp_allowlist_enabled_for_runtime(false, true));
738 assert!(!temp_allowlist_enabled_for_runtime(false, false));
739 }
740
741 {
742 let _env = EnvVarGuard::set(AICX_ALLOW_TMP_ENV, Some("1"));
743 assert!(temp_allowlist_enabled_for_runtime(false, false));
744 }
745
746 {
747 let _env = EnvVarGuard::set(AICX_ALLOW_TMP_ENV, Some("true"));
748 assert!(!temp_allowlist_enabled_for_runtime(false, false));
749 }
750 }
751
752 #[test]
753 fn test_cargo_test_exe_detection_accepts_custom_target_dir_layout() {
754 let path =
755 Path::new("/Users/runner/work/cache/aicx-macos/debug/deps/aicx-0b1797b9ba8904ee");
756 assert!(is_cargo_test_exe_path(path));
757 }
758
759 #[test]
760 fn test_cargo_test_exe_detection_rejects_normal_binary_paths() {
761 let path = Path::new("/Users/runner/work/aicx/target/debug/aicx");
762 assert!(!is_cargo_test_exe_path(path));
763 }
764
765 #[test]
766 fn test_current_user_allowed_bases_are_accepted() {
767 for base in current_user_allowed_bases().expect("current user dirs") {
768 assert!(
769 is_under_allowed_base(&base.join("aicx-sanitize-test")).expect("allowlist check"),
770 "current user base should be allowed: {}",
771 base.display()
772 );
773 }
774 }
775
776 #[cfg(target_os = "macos")]
777 #[test]
778 fn test_macos_other_user_path_rejected() {
779 let path = Path::new("/Users/user/Documents/secret.txt");
780 assert!(
781 !is_under_allowed_base(path).expect("allowlist check"),
782 "macOS /Users allowlist must not generalize across users"
783 );
784 }
785
786 #[test]
787 fn test_validate_read_path_existing() {
788 let _env = EnvVarGuard::set(AICX_ALLOW_TMP_ENV, None);
789 let tmp = std::env::temp_dir().join("ai-ctx-san-test-read");
790 let _ = fs::remove_dir_all(&tmp);
791 fs::create_dir_all(&tmp).unwrap();
792 let test_file = tmp.join("test.txt");
793 fs::write(&test_file, "test").unwrap();
794
795 let result = validate_read_path(&test_file);
796 assert!(result.is_ok(), "Failed: {:?}", result);
797
798 let _ = fs::remove_dir_all(&tmp);
799 }
800
801 #[test]
802 fn test_validate_read_path_traversal() {
803 let bad = Path::new("/tmp/../../../etc/passwd");
804 assert!(validate_read_path(bad).is_err());
805 }
806
807 #[test]
808 fn test_validate_read_path_nonexistent() {
809 let missing = Path::new("/tmp/ai-ctx-nonexistent-12345");
810 assert!(validate_read_path(missing).is_err());
811 }
812
813 #[test]
814 fn test_validate_write_path_new() {
815 let tmp = std::env::temp_dir().join("ai-ctx-san-test-write");
816 let _ = fs::create_dir_all(&tmp);
817 let new_file = tmp.join("new.txt");
818 let result = validate_write_path(&new_file);
819 assert!(result.is_ok(), "Failed: {:?}", result);
820 let _ = fs::remove_dir_all(&tmp);
821 }
822
823 #[test]
824 fn test_validate_write_path_traversal() {
825 let bad = Path::new("/tmp/../../../etc/evil.txt");
826 assert!(validate_write_path(bad).is_err());
827 }
828
829 #[test]
830 fn test_validate_write_path_rejects_non_allowed_ancestor() {
831 let bad = Path::new("/etc/ai-contexters-test/nope/file.txt");
832 assert!(validate_write_path(bad).is_err());
833 }
834
835 #[test]
836 fn test_validate_write_path_relative_with_missing_parents() {
837 let nested = Path::new("target/ai-ctx-sanitize-new/subdir/new.txt");
838 assert!(validate_write_path(nested).is_ok());
839 }
840
841 #[test]
842 fn test_validate_dir_path() {
843 let tmp = std::env::temp_dir();
844 assert!(validate_dir_path(&tmp).is_ok());
845 }
846
847 #[test]
848 fn test_open_file_validated() {
849 let tmp = std::env::temp_dir().join("ai-ctx-san-open-file");
850 let _ = fs::remove_dir_all(&tmp);
851 fs::create_dir_all(&tmp).unwrap();
852 let test_file = tmp.join("test.txt");
853 fs::write(&test_file, "hello").unwrap();
854
855 let mut opened = open_file_validated(&test_file).unwrap();
856 let mut content = String::new();
857 use std::io::Read as _;
858 opened.read_to_string(&mut content).unwrap();
859 assert_eq!(content, "hello");
860
861 let _ = fs::remove_dir_all(&tmp);
862 }
863
864 #[test]
865 fn test_read_to_string_validated() {
866 let tmp = std::env::temp_dir().join("ai-ctx-san-read-string");
867 let _ = fs::remove_dir_all(&tmp);
868 fs::create_dir_all(&tmp).unwrap();
869 let test_file = tmp.join("test.txt");
870 fs::write(&test_file, "hello").unwrap();
871
872 let content = read_to_string_validated(&test_file).unwrap();
873 assert_eq!(content, "hello");
874
875 let _ = fs::remove_dir_all(&tmp);
876 }
877
878 #[test]
879 fn test_create_file_validated() {
880 let tmp = std::env::temp_dir().join("ai-ctx-san-create-file");
881 let _ = fs::remove_dir_all(&tmp);
882 fs::create_dir_all(&tmp).unwrap();
883 let test_file = tmp.join("test.txt");
884
885 let mut created = create_file_validated(&test_file).unwrap();
886 use std::io::Write as _;
887 created.write_all(b"hello").unwrap();
888 drop(created);
889
890 let content = fs::read_to_string(&test_file).unwrap();
891 assert_eq!(content, "hello");
892
893 let _ = fs::remove_dir_all(&tmp);
894 }
895
896 #[test]
897 fn test_read_dir_validated() {
898 let tmp = std::env::temp_dir().join("ai-ctx-san-read-dir");
899 let _ = fs::remove_dir_all(&tmp);
900 fs::create_dir_all(&tmp).unwrap();
901 fs::write(tmp.join("a.txt"), "a").unwrap();
902
903 let entries = read_dir_validated(&tmp)
904 .unwrap()
905 .filter_map(|entry| entry.ok())
906 .count();
907 assert_eq!(entries, 1);
908
909 let _ = fs::remove_dir_all(&tmp);
910 }
911
912 #[test]
913 fn test_sanitize_strips_nul_byte_and_warns() {
914 let sanitized = sanitize_chunk_content("abc\0def\0");
915 assert_eq!(sanitized.text, "abcdef");
916 assert_eq!(
917 sanitized.warnings,
918 vec![
919 ContentSanitizationWarning::NullByteStripped(3),
920 ContentSanitizationWarning::NullByteStripped(7),
921 ]
922 );
923 }
924
925 #[test]
926 fn test_sanitize_normalizes_crlf_to_lf() {
927 let sanitized = sanitize_chunk_content("one\r\ntwo\rthree\nfour");
928 assert_eq!(sanitized.text, "one\ntwo\nthree\nfour");
929 assert!(sanitized.warnings.is_empty());
930 }
931
932 #[test]
933 fn test_sanitize_preserves_unicode_emoji() {
934 let sanitized = sanitize_chunk_content("ship it 🚀");
935 assert_eq!(sanitized.text, "ship it 🚀");
936 assert!(sanitized.warnings.is_empty());
937 }
938
939 #[test]
940 fn test_sanitize_preserves_polish_diacritics_nfc() {
941 let input = "Zażółć gęślą jaźń";
942 let sanitized = sanitize_chunk_content(input);
943 assert_eq!(sanitized.text, input);
944 assert!(sanitized.warnings.is_empty());
945 }
946
947 #[test]
948 fn test_sanitize_bidi_override_warns_but_does_not_strip() {
949 let input = "safe \u{202E}txt";
950 let sanitized = sanitize_chunk_content(input);
951 assert_eq!(sanitized.text, input);
952 assert_eq!(
953 sanitized.warnings,
954 vec![ContentSanitizationWarning::BidiOverride('\u{202E}', 5)]
955 );
956 }
957
958 #[test]
959 fn test_sanitize_zero_width_warns_but_does_not_strip() {
960 let input = "zero\u{200B}width";
961 let sanitized = sanitize_chunk_content(input);
962 assert_eq!(sanitized.text, input);
963 assert_eq!(
964 sanitized.warnings,
965 vec![ContentSanitizationWarning::ZeroWidth('\u{200B}', 4)]
966 );
967 }
968
969 #[test]
970 fn test_safe_agent_name_valid() {
971 assert_eq!(safe_agent_name("claude").unwrap(), "claude");
972 assert_eq!(safe_agent_name("codex").unwrap(), "codex");
973 assert_eq!(safe_agent_name("gemini").unwrap(), "gemini");
974 assert_eq!(safe_agent_name("junie").unwrap(), "junie");
975 assert_eq!(safe_agent_name("codescribe").unwrap(), "codescribe");
976 assert_eq!(safe_agent_name("operator-md").unwrap(), "operator-md");
977 }
978
979 #[test]
980 fn test_safe_agent_name_rejects_unknown() {
981 assert!(safe_agent_name("rm").is_err());
982 assert!(safe_agent_name("bash").is_err());
983 assert!(safe_agent_name("claude; rm -rf /").is_err());
984 }
985
986 #[test]
987 fn test_safe_project_name_valid() {
988 assert!(safe_project_name("my-project").is_ok());
989 assert!(safe_project_name("lbrx-services").is_ok());
990 assert!(safe_project_name("Codescribe").is_ok());
991 }
992}
993
994pub fn normalize_query(text: &str) -> String {
1009 use unicode_normalization::UnicodeNormalization;
1010 text.nfc()
1011 .map(|c| match c {
1012 'Ą' | 'ą' => 'a',
1013 'Ć' | 'ć' => 'c',
1014 'Ę' | 'ę' => 'e',
1015 'Ł' | 'ł' => 'l',
1016 'Ń' | 'ń' => 'n',
1017 'Ó' | 'ó' => 'o',
1018 'Ś' | 'ś' => 's',
1019 'Ź' | 'ź' | 'Ż' | 'ż' => 'z',
1020 _ => c,
1021 })
1022 .collect::<String>()
1023 .to_lowercase()
1024}
1025
1026const STABLE_SELF_ECHO_PATTERNS: &[&str] = &[
1038 "aicx_search",
1040 "aicx_read",
1041 "aicx_rank",
1042 "aicx_refs",
1043 "aicx_store",
1044 "/api/search/fuzzy",
1046 "/api/search/semantic",
1047 "/api/search/cross",
1048 "/api/health",
1049 "/api/regenerate",
1050 "/api/status",
1051];
1052
1053const MCP_JSONRPC_CATCH_ALL: &str = "\"jsonrpc\":\"2.0\"";
1061
1062const RETIRED_CLI_SUBCOMMANDS: &[&str] = &[
1067 "rank",
1069 "gemini", "junie",
1072];
1073
1074pub const CLI_SUBCOMMAND_NAMES: &[&str] = &[
1092 "all",
1093 "claims",
1094 "clarify",
1095 "claude",
1096 "codex",
1097 "config",
1098 "conversations",
1099 "corpus",
1100 "dashboard",
1101 "dashboard-serve",
1102 "doctor",
1103 "eval",
1104 "extract",
1105 "health",
1106 "index",
1107 "ingest",
1108 "init",
1109 "intents",
1110 "list",
1111 "migrate",
1112 "migrate-intent-schema",
1113 "open",
1114 "read",
1115 "refs",
1116 "reports",
1117 "reports-extractor",
1118 "results",
1119 "search",
1120 "serve",
1121 "session",
1122 "sessions",
1123 "sources",
1124 "state",
1125 "steer",
1126 "store",
1127 "tail",
1128 "warmup",
1129 "wizard",
1130];
1131
1132fn build_self_echo_patterns(extra: &[String]) -> Vec<String> {
1140 let mut patterns: Vec<String> = Vec::with_capacity(
1141 STABLE_SELF_ECHO_PATTERNS.len()
1142 + CLI_SUBCOMMAND_NAMES.len()
1143 + RETIRED_CLI_SUBCOMMANDS.len()
1144 + extra.len()
1145 + 1,
1146 );
1147 for pat in STABLE_SELF_ECHO_PATTERNS {
1148 patterns.push((*pat).to_string());
1149 }
1150 patterns.push(MCP_JSONRPC_CATCH_ALL.to_string());
1151 for name in CLI_SUBCOMMAND_NAMES.iter().chain(RETIRED_CLI_SUBCOMMANDS) {
1152 patterns.push(format!("aicx {name}"));
1153 }
1154 for pat in extra {
1155 let trimmed = pat.trim();
1156 if !trimmed.is_empty() {
1157 patterns.push(trimmed.to_string());
1158 }
1159 }
1160 patterns
1161}
1162
1163static SELF_ECHO_PATTERNS_CACHE: std::sync::OnceLock<Vec<String>> = std::sync::OnceLock::new();
1167
1168fn active_self_echo_patterns() -> &'static [String] {
1172 SELF_ECHO_PATTERNS_CACHE.get_or_init(|| {
1173 let extras = load_extra_self_echo_patterns_from_config();
1174 build_self_echo_patterns(&extras)
1175 .into_iter()
1176 .map(|p| p.to_lowercase())
1177 .collect()
1178 })
1179}
1180
1181fn load_extra_self_echo_patterns_from_config() -> Vec<String> {
1193 #[derive(serde::Deserialize, Default)]
1194 struct ExtractionFile {
1195 #[serde(default)]
1196 extraction: Option<ExtractionSection>,
1197 }
1198 #[derive(serde::Deserialize, Default)]
1199 struct ExtractionSection {
1200 #[serde(default)]
1201 extra_self_echo_patterns: Vec<String>,
1202 }
1203
1204 let base = std::env::var_os("AICX_HOME")
1205 .map(PathBuf::from)
1206 .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".aicx")));
1207 let Some(base) = base else {
1208 return Vec::new();
1209 };
1210 let path = base.join("config.toml");
1211 let raw = match read_to_string_validated(&path) {
1212 Ok(raw) => raw,
1213 Err(_) => return Vec::new(),
1214 };
1215 let parsed: ExtractionFile = match toml::from_str(&raw) {
1216 Ok(parsed) => parsed,
1217 Err(_) => return Vec::new(),
1218 };
1219 parsed
1220 .extraction
1221 .map(|section| section.extra_self_echo_patterns)
1222 .unwrap_or_default()
1223 .into_iter()
1224 .map(|p| p.trim().to_string())
1225 .filter(|p| !p.is_empty())
1226 .collect()
1227}
1228
1229const AICX_READ_BEGIN: &str = "【aicx:read】";
1232const AICX_READ_END: &str = "【/aicx:read】";
1233
1234pub fn is_self_echo(message: &str) -> bool {
1241 let lines: Vec<&str> = message
1242 .lines()
1243 .map(str::trim)
1244 .filter(|l| !l.is_empty())
1245 .collect();
1246
1247 if lines.is_empty() {
1248 return false;
1249 }
1250
1251 let mut echo_lines = 0usize;
1252 let mut inside_aicx_block = false;
1253
1254 for line in &lines {
1255 if line.contains(AICX_READ_BEGIN) {
1256 inside_aicx_block = true;
1257 echo_lines += 1;
1258 continue;
1259 }
1260 if line.contains(AICX_READ_END) {
1261 inside_aicx_block = false;
1262 echo_lines += 1;
1263 continue;
1264 }
1265 if inside_aicx_block {
1266 echo_lines += 1;
1267 continue;
1268 }
1269 let lower = line.to_lowercase();
1270 if active_self_echo_patterns()
1271 .iter()
1272 .any(|pat| lower.contains(pat))
1273 {
1274 echo_lines += 1;
1275 }
1276 }
1277
1278 echo_lines > 0 && echo_lines * 2 > lines.len()
1280}
1281
1282pub fn filter_self_echo<T>(entries: Vec<T>, get_message: impl Fn(&T) -> &str) -> Vec<T> {
1284 entries
1285 .into_iter()
1286 .filter(|e| !is_self_echo(get_message(e)))
1287 .collect()
1288}
1289
1290#[cfg(test)]
1291mod echo_tests {
1292 use super::*;
1293
1294 #[test]
1295 fn test_normal_message_not_echo() {
1296 assert!(!is_self_echo("Fix the login regression in auth middleware"));
1297 assert!(!is_self_echo("Decision: use per-chunk scoring"));
1298 assert!(!is_self_echo("TODO: add tests for edge cases"));
1299 }
1300
1301 #[test]
1302 fn test_search_call_is_echo() {
1303 assert!(is_self_echo(
1304 r#"{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"aicx_search","arguments":{"query":"deploy vistacare"}}}"#
1305 ));
1306 }
1307
1308 #[test]
1309 fn test_api_call_is_echo() {
1310 assert!(is_self_echo(
1311 r#"curl -s "http://127.0.0.1:8033/api/search/fuzzy?q=deploy+vistacare&limit=3""#
1312 ));
1313 }
1314
1315 #[test]
1316 fn test_cli_self_invocation_is_echo() {
1317 assert!(is_self_echo("aicx all -H 24 --emit none"));
1318 assert!(is_self_echo("aicx store -H 24 --full-rescan"));
1319 assert!(is_self_echo("aicx store --hours 24"));
1320 assert!(is_self_echo("aicx rank -p ai-contexters -H 72 --strict"));
1321 assert!(is_self_echo(
1322 "aicx dashboard --generate-html -p ai-contexters -H 24"
1323 ));
1324 assert!(is_self_echo(
1325 "aicx reports --repo ai-contexters --workflow marbles"
1326 ));
1327 }
1328
1329 #[test]
1330 fn test_mention_in_larger_message_not_echo() {
1331 let msg = "We should add aicx_search to the MCP server.\n\
1333 The architecture looks clean.\n\
1334 Let's proceed with implementation.\n\
1335 Decision: expose 4 tools via rmcp.";
1336 assert!(!is_self_echo(msg));
1337 }
1338
1339 #[test]
1340 fn test_self_echo_exactly_half_is_not_majority() {
1341 let msg = "aicx all -H 24 --emit none\n\
1342 Decision: preserve real operator signal\n\
1343 aicx store -H 24 --full-rescan\n\
1344 Root cause: threshold was too wide";
1345 assert!(!is_self_echo(msg));
1346 }
1347
1348 #[test]
1349 fn test_self_echo_just_above_half_is_echo() {
1350 let msg = "aicx all -H 24 --emit none\n\
1351 Decision: preserve real operator signal\n\
1352 aicx store -H 24 --full-rescan\n\
1353 Root cause: threshold was too wide\n\
1354 aicx refs -H 24";
1355 assert!(is_self_echo(msg));
1356 }
1357
1358 #[test]
1359 fn test_self_echo_just_below_half_is_not_echo() {
1360 let msg = "aicx all -H 24 --emit none\n\
1361 Decision: preserve real operator signal\n\
1362 aicx store -H 24 --full-rescan\n\
1363 Root cause: threshold was too wide\n\
1364 Follow-up: add focused coverage";
1365 assert!(!is_self_echo(msg));
1366 }
1367
1368 #[test]
1380 fn test_state_subcommand_is_echo() {
1381 let msg = "aicx state --info\n\
1384 aicx state --reset\n\
1385 aicx state -p vetcoders/Vista";
1386 assert!(
1387 is_self_echo(msg),
1388 "`aicx state` invocations should be classified as self-echo"
1389 );
1390 }
1391
1392 #[test]
1393 fn test_doctor_subcommand_is_echo() {
1394 let msg = "aicx doctor --oracle\n\
1395 aicx doctor --rebuild-steer-index\n\
1396 aicx doctor --check-dedup";
1397 assert!(
1398 is_self_echo(msg),
1399 "`aicx doctor` invocations should be classified as self-echo"
1400 );
1401 }
1402
1403 #[test]
1415 fn test_build_self_echo_patterns_includes_config_extras() {
1416 let extras = vec![
1417 "custom-tool-name".to_string(),
1418 " /api/internal/foo ".to_string(), "".to_string(), ];
1421 let patterns = build_self_echo_patterns(&extras);
1422
1423 assert!(
1424 patterns.iter().any(|p| p == "custom-tool-name"),
1425 "non-empty extra pattern must appear verbatim in the materialized list"
1426 );
1427 assert!(
1428 patterns.iter().any(|p| p == "/api/internal/foo"),
1429 "extra patterns must be trimmed of surrounding whitespace"
1430 );
1431 assert!(
1432 !patterns.iter().any(|p| p.is_empty()),
1433 "empty extras must be filtered out"
1434 );
1435
1436 assert!(patterns.iter().any(|p| p == "aicx_search"));
1438 assert!(patterns.iter().any(|p| p == "aicx doctor"));
1439 assert!(patterns.iter().any(|p| p == "aicx rank"));
1440 assert!(patterns.iter().any(|p| p == MCP_JSONRPC_CATCH_ALL));
1441 }
1442
1443 #[test]
1444 fn test_build_self_echo_patterns_empty_extras_matches_baseline() {
1445 let baseline = build_self_echo_patterns(&[]);
1448 assert!(
1449 baseline.iter().any(|p| p == "aicx_search"),
1450 "MCP tool names must survive an empty extras list"
1451 );
1452 assert!(
1453 baseline.iter().any(|p| p == "aicx doctor"),
1454 "CLI subcommand patterns must survive an empty extras list"
1455 );
1456 }
1457
1458 #[test]
1459 fn test_load_extra_self_echo_patterns_from_config_reads_extraction_section() {
1460 use std::fs;
1461 use std::sync::atomic::{AtomicUsize, Ordering};
1462
1463 static UNIQ: AtomicUsize = AtomicUsize::new(0);
1464 let dir = std::env::temp_dir().join(format!(
1465 "aicx-extraction-config-{}-{}",
1466 std::process::id(),
1467 UNIQ.fetch_add(1, Ordering::SeqCst)
1468 ));
1469 fs::create_dir_all(&dir).unwrap();
1470 fs::write(
1471 dir.join("config.toml"),
1472 r#"
1473[extraction]
1474extra_self_echo_patterns = [
1475 "operator-marker-alpha",
1476 " /api/operator/beta ",
1477 "",
1478]
1479"#,
1480 )
1481 .unwrap();
1482
1483 let prev = std::env::var_os("AICX_HOME");
1487 unsafe {
1492 std::env::set_var("AICX_HOME", &dir);
1493 }
1494 let loaded = load_extra_self_echo_patterns_from_config();
1495 unsafe {
1496 match prev {
1497 Some(v) => std::env::set_var("AICX_HOME", v),
1498 None => std::env::remove_var("AICX_HOME"),
1499 }
1500 }
1501
1502 assert!(
1503 loaded.iter().any(|p| p == "operator-marker-alpha"),
1504 "loader must surface verbatim operator-supplied pattern; got {loaded:?}"
1505 );
1506 assert!(
1507 loaded.iter().any(|p| p == "/api/operator/beta"),
1508 "loader must trim whitespace; got {loaded:?}"
1509 );
1510 assert!(
1511 !loaded.iter().any(|p| p.is_empty()),
1512 "loader must drop empty entries; got {loaded:?}"
1513 );
1514
1515 let _ = fs::remove_dir_all(&dir);
1516 }
1517
1518 #[test]
1519 fn test_load_extra_self_echo_patterns_returns_empty_when_no_config() {
1520 use std::sync::atomic::{AtomicUsize, Ordering};
1521
1522 static UNIQ: AtomicUsize = AtomicUsize::new(0);
1523 let nonexistent = std::env::temp_dir().join(format!(
1524 "aicx-extraction-config-nope-{}-{}",
1525 std::process::id(),
1526 UNIQ.fetch_add(1, Ordering::SeqCst)
1527 ));
1528 let prev = std::env::var_os("AICX_HOME");
1531 unsafe {
1532 std::env::set_var("AICX_HOME", &nonexistent);
1533 }
1534 let loaded = load_extra_self_echo_patterns_from_config();
1535 unsafe {
1536 match prev {
1537 Some(v) => std::env::set_var("AICX_HOME", v),
1538 None => std::env::remove_var("AICX_HOME"),
1539 }
1540 }
1541
1542 assert!(
1543 loaded.is_empty(),
1544 "missing config must yield empty extras, not panic; got {loaded:?}"
1545 );
1546 }
1547
1548 #[test]
1549 fn test_mcp_jsonrpc_catch_all_matches_unknown_method() {
1550 assert!(
1557 is_self_echo(
1558 r#"{"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":7}}"#
1559 ),
1560 "unknown JSON-RPC method should be caught by the generic catch-all"
1561 );
1562 assert!(
1563 is_self_echo(r#"{"jsonrpc":"2.0","method":"resources/list","params":{}}"#),
1564 "resources/list JSON-RPC must be filtered as self-echo"
1565 );
1566 assert!(
1567 is_self_echo(
1568 r#"{"jsonrpc":"2.0","id":42,"result":{"contents":[{"uri":"aicx://chunk/abc"}]}}"#
1569 ),
1570 "JSON-RPC result envelope (no method name at all) must still classify as self-echo"
1571 );
1572 }
1573
1574 #[test]
1575 fn test_corpus_index_warmup_wizard_config_subcommands_are_echo() {
1576 for line in [
1580 "aicx corpus --audit",
1581 "aicx index --dry-run",
1582 "aicx warmup",
1583 "aicx wizard",
1584 "aicx config show",
1585 "aicx steer --run-id abc",
1586 "aicx tail --follow",
1587 "aicx ingest --source loct-context-pack",
1588 "aicx migrate --dry-run",
1589 "aicx sources audit",
1590 "aicx serve --transport stdio",
1591 "aicx extract --session abc --agent claude",
1592 "aicx read /some/path",
1593 "aicx search hello",
1594 "aicx refs -H 24",
1595 "aicx reports --workflow marbles",
1596 "aicx intents -H 720",
1597 "aicx conversations --out-dir /tmp",
1598 "aicx health",
1599 "aicx list",
1600 "aicx claude -H 24",
1601 "aicx codex -H 24",
1602 "aicx all -H 24",
1603 "aicx store -H 24",
1604 ] {
1605 assert!(
1606 is_self_echo(line),
1607 "`{line}` should be classified as self-echo (Commands variant missing from pattern list?)"
1608 );
1609 }
1610 }
1611}
1612
1613#[cfg(test)]
1614mod normalize_tests {
1615 use super::*;
1616
1617 #[test]
1618 fn test_normalize_query_strips_diacritics() {
1619 assert_eq!(normalize_query("wdrożenie"), "wdrozenie");
1620 assert_eq!(normalize_query("źródło ŁĄCZNOŚCI"), "zrodlo lacznosci");
1621 assert_eq!(normalize_query("Deploy Vista"), "deploy vista");
1622 assert_eq!(normalize_query("ąćęłńóśźż"), "acelnoszz");
1623 }
1624
1625 #[test]
1626 fn test_normalize_query_unifies_nfc_and_nfd_inputs() {
1627 use unicode_normalization::UnicodeNormalization;
1631 let composed = "źródło";
1632 let decomposed: String = composed.nfd().collect();
1633 assert_ne!(
1634 composed, decomposed,
1635 "test pre-condition: NFC and NFD forms must differ at the byte level"
1636 );
1637 assert_eq!(
1638 normalize_query(composed),
1639 normalize_query(&decomposed),
1640 "NFC pre-pass must collapse pre-composed and decomposed diacritics"
1641 );
1642 assert_eq!(normalize_query(&decomposed), "zrodlo");
1643 }
1644
1645 #[test]
1646 fn test_safe_project_name_rejects_bad() {
1647 assert!(safe_project_name("../etc").is_err());
1648 assert!(safe_project_name("foo/bar").is_err());
1649 assert!(safe_project_name("").is_err());
1650 assert!(safe_project_name("foo\0bar").is_err());
1651 }
1652}