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 Path::new(path)
123 .components()
124 .any(|c| matches!(c, Component::ParentDir))
125}
126
127fn current_user_allowed_bases() -> Result<Vec<PathBuf>> {
128 let mut bases = Vec::new();
129 for base in [dirs::home_dir(), dirs::cache_dir(), dirs::data_dir()]
130 .into_iter()
131 .flatten()
132 {
133 if !bases.iter().any(|existing| existing == &base) {
134 bases.push(base);
135 }
136 }
137
138 if bases.is_empty() {
139 return Err(anyhow!(
140 "Cannot determine current user allowed base directories"
141 ));
142 }
143
144 Ok(bases)
145}
146
147fn canonicalize_existing(path: &Path) -> Result<PathBuf> {
149 path.canonicalize()
150 .map_err(|e| anyhow!("Cannot canonicalize path '{}': {}", path.display(), e))
151}
152
153fn temp_allowlist_enabled() -> bool {
157 temp_allowlist_enabled_for_runtime(cfg!(test), running_under_cargo_test_harness())
158}
159
160fn temp_allowlist_enabled_for_runtime(is_test_build: bool, is_cargo_test_harness: bool) -> bool {
161 is_test_build
162 || is_cargo_test_harness
163 || std::env::var(AICX_ALLOW_TMP_ENV).is_ok_and(|value| value == "1")
164}
165
166fn running_under_cargo_test_harness() -> bool {
167 std::env::current_exe()
168 .ok()
169 .is_some_and(|exe| is_cargo_test_exe_path(&exe))
170}
171
172fn is_cargo_test_exe_path(path: &Path) -> bool {
173 let has_deps_component = path.components().any(|component| {
174 matches!(
175 component,
176 Component::Normal(text) if text == std::ffi::OsStr::new("deps")
177 )
178 });
179 if !has_deps_component {
180 return false;
181 }
182
183 path.file_stem()
184 .and_then(|stem| stem.to_str())
185 .and_then(|stem| stem.rsplit_once('-'))
186 .is_some_and(|(_, suffix)| {
187 suffix.len() >= 8 && suffix.chars().all(|ch| ch.is_ascii_hexdigit())
188 })
189}
190
191fn is_temp_allowlist_path(path: &Path) -> bool {
192 path.starts_with("/tmp")
193 || path.starts_with("/var/folders")
194 || path.starts_with("/private/tmp")
195 || path.starts_with("/private/var/folders")
196}
197
198fn is_under_allowed_base(path: &Path) -> Result<bool> {
200 for base in current_user_allowed_bases()? {
201 if path.starts_with(base) {
202 return Ok(true);
203 }
204 }
205
206 if is_temp_allowlist_path(path) {
207 return Ok(temp_allowlist_enabled());
208 }
209
210 Ok(false)
211}
212
213pub fn validate_read_path(path: &Path) -> Result<PathBuf> {
221 let path_str = path.to_string_lossy();
222 if contains_traversal(&path_str) {
223 return Err(anyhow!(
224 "Path contains invalid traversal sequence: {}",
225 path_str
226 ));
227 }
228
229 if !path.exists() {
230 return Err(anyhow!("Path does not exist: {}", path.display()));
231 }
232
233 let canonical = canonicalize_existing(path)?;
234
235 if !is_under_allowed_base(&canonical)? {
236 return Err(anyhow!(
237 "Cannot read from path outside allowed directories: {}",
238 canonical.display()
239 ));
240 }
241
242 Ok(canonical)
243}
244
245pub fn validate_write_path(path: &Path) -> Result<PathBuf> {
249 let path_str = path.to_string_lossy();
250 if contains_traversal(&path_str) {
251 return Err(anyhow!(
252 "Path contains invalid traversal sequence: {}",
253 path_str
254 ));
255 }
256
257 if path.exists() {
258 let canonical = canonicalize_existing(path)?;
259 if !is_under_allowed_base(&canonical)? {
260 return Err(anyhow!(
261 "Cannot write to path outside allowed directories: {}",
262 canonical.display()
263 ));
264 }
265 return Ok(canonical);
266 }
267
268 let candidate = if path.is_absolute() {
270 path.to_path_buf()
271 } else {
272 std::env::current_dir()
273 .map_err(|e| anyhow!("Cannot determine current directory: {}", e))?
274 .join(path)
275 };
276
277 let mut ancestor = Some(candidate.as_path());
278 let mut existing_ancestor = None;
279 while let Some(current) = ancestor {
280 if current.exists() {
281 existing_ancestor = Some(canonicalize_existing(current)?);
282 break;
283 }
284 ancestor = current.parent();
285 }
286
287 let canonical_base = existing_ancestor.ok_or_else(|| {
288 anyhow!(
289 "Cannot validate write path '{}': no existing ancestor found",
290 path.display()
291 )
292 })?;
293
294 if !is_under_allowed_base(&canonical_base)? {
295 return Err(anyhow!(
296 "Path '{}' would be created outside allowed directories",
297 path.display()
298 ));
299 }
300
301 Ok(path.to_path_buf())
302}
303
304pub fn validate_dir_path(path: &Path) -> Result<PathBuf> {
308 let validated = validate_read_path(path)?;
309 if !validated.is_dir() {
310 return Err(anyhow!("Path is not a directory: {}", validated.display()));
311 }
312 Ok(validated)
313}
314
315pub fn open_file_validated(path: &Path) -> Result<std::fs::File> {
317 let validated = validate_read_path(path)?;
318 std::fs::OpenOptions::new()
319 .read(true)
320 .open(&validated)
321 .map_err(|e| anyhow!("Failed to open '{}': {}", validated.display(), e))
322}
323
324pub fn create_file_validated(path: &Path) -> Result<std::fs::File> {
326 let validated = validate_write_path(path)?;
327 std::fs::OpenOptions::new()
328 .create(true)
329 .truncate(true)
330 .write(true)
331 .open(&validated)
332 .map_err(|e| anyhow!("Failed to create '{}': {}", validated.display(), e))
333}
334
335pub fn read_to_string_validated(path: &Path) -> Result<String> {
337 read_to_string_with_cap(path, MAX_VALIDATED_BYTES, false)
338}
339
340pub fn read_state_json_validated(path: &Path) -> Result<String> {
350 read_to_string_with_cap(path, MAX_STATE_JSON_BYTES, true)
351}
352
353fn read_to_string_with_cap(path: &Path, max_bytes: usize, is_state: bool) -> Result<String> {
354 let validated = validate_read_path(path)?;
355 let file = std::fs::OpenOptions::new()
356 .read(true)
357 .open(&validated)
358 .map_err(|e| anyhow!("Failed to open '{}': {}", validated.display(), e))?;
359 let metadata = file
360 .metadata()
361 .map_err(|e| anyhow!("Failed to stat '{}': {}", validated.display(), e))?;
362 if metadata.len() > max_bytes as u64 {
363 return Err(too_large_error(validated, max_bytes, metadata.len(), is_state).into());
364 }
365
366 let mut reader = file.take(max_bytes.saturating_add(1) as u64);
367 let mut bytes = Vec::new();
368 reader
369 .read_to_end(&mut bytes)
370 .map_err(|e| anyhow!("Failed to read '{}': {}", validated.display(), e))?;
371 if bytes.len() > max_bytes {
372 return Err(too_large_error(validated, max_bytes, bytes.len() as u64, is_state).into());
373 }
374 String::from_utf8(bytes).map_err(|e| anyhow!("Failed to read '{}': {}", validated.display(), e))
375}
376
377fn too_large_error(
378 path: PathBuf,
379 max_bytes: usize,
380 actual_bytes: u64,
381 is_state: bool,
382) -> SanitizeError {
383 if is_state {
384 SanitizeError::StateFileTooLarge {
385 path,
386 max_bytes,
387 actual_bytes,
388 }
389 } else {
390 SanitizeError::FileTooLarge {
391 path,
392 max_bytes,
393 actual_bytes,
394 }
395 }
396}
397
398pub fn read_dir_validated(path: &Path) -> Result<std::fs::ReadDir> {
400 let validated = validate_dir_path(path)?;
401 std::fs::read_dir(&validated)
407 .map_err(|e| anyhow!("Failed to read dir '{}': {}", validated.display(), e))
408}
409
410pub fn read_line_capped<R: BufRead>(
411 reader: &mut R,
412 max_bytes: usize,
413) -> io::Result<Option<CappedLine>> {
414 let mut buf = Vec::new();
415 let read = {
416 let mut limited = reader.take(max_bytes.saturating_add(1) as u64);
417 limited.read_until(b'\n', &mut buf)?
418 };
419 if read == 0 {
420 return Ok(None);
421 }
422
423 let exceeded = buf.len() > max_bytes;
424 if exceeded {
425 let ended_at_newline = buf.last().copied() == Some(b'\n');
426 buf.truncate(max_bytes);
427 while let Some(&last) = buf.last() {
431 if (last & 0xC0) == 0x80 {
432 buf.pop();
433 } else if last >= 0xC0 {
434 buf.pop();
437 break;
438 } else {
439 break;
440 }
441 }
442 if !ended_at_newline {
443 drain_until_newline(reader)?;
444 }
445 }
446
447 let line =
448 String::from_utf8(buf).map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?;
449 Ok(Some(CappedLine { line, exceeded }))
450}
451
452fn drain_until_newline<R: BufRead>(reader: &mut R) -> io::Result<()> {
453 loop {
454 let available = reader.fill_buf()?;
455 if available.is_empty() {
456 return Ok(());
457 }
458 let consume = available
459 .iter()
460 .position(|byte| *byte == b'\n')
461 .map_or(available.len(), |idx| idx + 1);
462 let ended_at_newline = available.get(consume.saturating_sub(1)) == Some(&b'\n');
463 reader.consume(consume);
464 if ended_at_newline {
465 return Ok(());
466 }
467 }
468}
469
470pub fn safe_agent_name(name: &str) -> Result<&str> {
479 if ALLOWED_AGENTS.contains(&name) {
480 Ok(name)
481 } else {
482 Err(anyhow!(
483 "Unknown agent: {:?}. Allowed: {}",
484 name,
485 ALLOWED_AGENTS.join(", ")
486 ))
487 }
488}
489
490pub fn safe_project_name(name: &str) -> Result<&str> {
495 if name.is_empty() {
496 return Err(anyhow!("Project name cannot be empty"));
497 }
498 if contains_traversal(name) || name.contains('/') || name.contains('\\') {
499 return Err(anyhow!("Invalid project name: {:?}", name));
500 }
501 Ok(name)
502}
503
504pub fn sanitize_chunk_content(text: &str) -> SanitizedContent<'_> {
509 let (text, warnings) = sanitize_message_content(text);
510 SanitizedContent { text, warnings }
511}
512
513fn sanitize_message_content(input: &str) -> (Cow<'_, str>, Vec<ContentSanitizationWarning>) {
514 let mut output: Option<String> = None;
515 let mut warnings = Vec::new();
516 let mut chars = input.char_indices().peekable();
517
518 while let Some((offset, ch)) = chars.next() {
519 match ch {
520 '\0' => {
521 warnings.push(ContentSanitizationWarning::NullByteStripped(offset));
522 ensure_output(&mut output, input, offset);
523 }
524 '\r' => {
525 ensure_output(&mut output, input, offset);
526 output.as_mut().expect("output initialized").push('\n');
527 if chars.peek().is_some_and(|(_, next)| *next == '\n') {
528 chars.next();
529 }
530 }
531 _ => {
532 if is_bidi_override(ch) {
533 warnings.push(ContentSanitizationWarning::BidiOverride(ch, offset));
534 } else if is_zero_width(ch) {
535 warnings.push(ContentSanitizationWarning::ZeroWidth(ch, offset));
536 }
537
538 if let Some(out) = output.as_mut() {
539 out.push(ch);
540 }
541 }
542 }
543 }
544
545 match output {
546 Some(output) => (Cow::Owned(output), warnings),
547 None => (Cow::Borrowed(input), warnings),
548 }
549}
550
551fn ensure_output(output: &mut Option<String>, input: &str, offset: usize) {
552 if output.is_none() {
553 let mut owned = String::with_capacity(input.len());
554 owned.push_str(&input[..offset]);
555 *output = Some(owned);
556 }
557}
558
559fn is_bidi_override(ch: char) -> bool {
560 matches!(
561 ch,
562 '\u{202A}'
563 | '\u{202B}'
564 | '\u{202C}'
565 | '\u{202D}'
566 | '\u{202E}'
567 | '\u{2066}'
568 | '\u{2067}'
569 | '\u{2068}'
570 | '\u{2069}'
571 )
572}
573
574fn is_zero_width(ch: char) -> bool {
575 matches!(ch, '\u{200B}' | '\u{200C}' | '\u{200D}' | '\u{FEFF}')
576}
577
578#[cfg(test)]
583mod tests {
584 use super::*;
585 use std::fs;
586 use std::sync::{Mutex, MutexGuard};
587
588 static ENV_MUTEX: Mutex<()> = Mutex::new(());
589
590 struct EnvVarGuard {
591 key: &'static str,
592 previous: Option<std::ffi::OsString>,
593 _guard: MutexGuard<'static, ()>,
594 }
595
596 impl EnvVarGuard {
597 fn set(key: &'static str, value: Option<&str>) -> Self {
598 let guard = ENV_MUTEX.lock().unwrap();
599 let previous = std::env::var_os(key);
600 unsafe {
603 match value {
604 Some(value) => std::env::set_var(key, value),
605 None => std::env::remove_var(key),
606 }
607 }
608 Self {
609 key,
610 previous,
611 _guard: guard,
612 }
613 }
614 }
615
616 impl Drop for EnvVarGuard {
617 fn drop(&mut self) {
618 unsafe {
621 match &self.previous {
622 Some(previous) => std::env::set_var(self.key, previous),
623 None => std::env::remove_var(self.key),
624 }
625 }
626 }
627 }
628
629 #[test]
630 fn test_contains_traversal() {
631 assert!(contains_traversal("../etc/passwd"));
632 assert!(contains_traversal("foo/../bar"));
633 assert!(contains_traversal("path\0with\0nulls"));
634 assert!(contains_traversal("line\nbreak"));
635 assert!(!contains_traversal("/normal/path"));
636 assert!(!contains_traversal("simple_name"));
637 assert!(!contains_traversal("./relative/path"));
638 }
639
640 #[test]
641 fn test_contains_traversal_does_not_flag_three_dot_folder() {
642 assert!(!contains_traversal("..."));
646 assert!(!contains_traversal("/Users/foo/.aicx/store/..."));
647 assert!(!contains_traversal("foo/.../bar"));
648 }
649
650 #[test]
651 fn test_contains_traversal_does_not_flag_dot_dot_inside_name() {
652 assert!(!contains_traversal("foo..bar"));
655 assert!(!contains_traversal("a..b/c"));
656 assert!(!contains_traversal("normal..text"));
657 assert!(!contains_traversal("/srv/a..b/c"));
658 }
659
660 #[test]
661 fn test_contains_traversal_carriage_return() {
662 assert!(contains_traversal("path\rwith\rcr"));
663 }
664
665 #[test]
666 fn test_tmp_allowlist_hybrid_policy() {
667 {
668 let _env = EnvVarGuard::set(AICX_ALLOW_TMP_ENV, None);
669 assert!(temp_allowlist_enabled_for_runtime(true, false));
670 assert!(temp_allowlist_enabled_for_runtime(false, true));
671 assert!(!temp_allowlist_enabled_for_runtime(false, false));
672 }
673
674 {
675 let _env = EnvVarGuard::set(AICX_ALLOW_TMP_ENV, Some("1"));
676 assert!(temp_allowlist_enabled_for_runtime(false, false));
677 }
678
679 {
680 let _env = EnvVarGuard::set(AICX_ALLOW_TMP_ENV, Some("true"));
681 assert!(!temp_allowlist_enabled_for_runtime(false, false));
682 }
683 }
684
685 #[test]
686 fn test_cargo_test_exe_detection_accepts_custom_target_dir_layout() {
687 let path =
688 Path::new("/Users/runner/work/cache/aicx-macos/debug/deps/aicx-0b1797b9ba8904ee");
689 assert!(is_cargo_test_exe_path(path));
690 }
691
692 #[test]
693 fn test_cargo_test_exe_detection_rejects_normal_binary_paths() {
694 let path = Path::new("/Users/runner/work/aicx/target/debug/aicx");
695 assert!(!is_cargo_test_exe_path(path));
696 }
697
698 #[test]
699 fn test_current_user_allowed_bases_are_accepted() {
700 for base in current_user_allowed_bases().expect("current user dirs") {
701 assert!(
702 is_under_allowed_base(&base.join("aicx-sanitize-test")).expect("allowlist check"),
703 "current user base should be allowed: {}",
704 base.display()
705 );
706 }
707 }
708
709 #[cfg(target_os = "macos")]
710 #[test]
711 fn test_macos_other_user_path_rejected() {
712 let path = Path::new("/Users/other_user/Documents/secret.txt");
713 assert!(
714 !is_under_allowed_base(path).expect("allowlist check"),
715 "macOS /Users allowlist must not generalize across users"
716 );
717 }
718
719 #[test]
720 fn test_validate_read_path_existing() {
721 let _env = EnvVarGuard::set(AICX_ALLOW_TMP_ENV, None);
722 let tmp = std::env::temp_dir().join("ai-ctx-san-test-read");
723 let _ = fs::remove_dir_all(&tmp);
724 fs::create_dir_all(&tmp).unwrap();
725 let test_file = tmp.join("test.txt");
726 fs::write(&test_file, "test").unwrap();
727
728 let result = validate_read_path(&test_file);
729 assert!(result.is_ok(), "Failed: {:?}", result);
730
731 let _ = fs::remove_dir_all(&tmp);
732 }
733
734 #[test]
735 fn test_validate_read_path_traversal() {
736 let bad = Path::new("/tmp/../../../etc/passwd");
737 assert!(validate_read_path(bad).is_err());
738 }
739
740 #[test]
741 fn test_validate_read_path_nonexistent() {
742 let missing = Path::new("/tmp/ai-ctx-nonexistent-12345");
743 assert!(validate_read_path(missing).is_err());
744 }
745
746 #[test]
747 fn test_validate_write_path_new() {
748 let tmp = std::env::temp_dir().join("ai-ctx-san-test-write");
749 let _ = fs::create_dir_all(&tmp);
750 let new_file = tmp.join("new.txt");
751 let result = validate_write_path(&new_file);
752 assert!(result.is_ok(), "Failed: {:?}", result);
753 let _ = fs::remove_dir_all(&tmp);
754 }
755
756 #[test]
757 fn test_validate_write_path_traversal() {
758 let bad = Path::new("/tmp/../../../etc/evil.txt");
759 assert!(validate_write_path(bad).is_err());
760 }
761
762 #[test]
763 fn test_validate_write_path_rejects_non_allowed_ancestor() {
764 let bad = Path::new("/etc/ai-contexters-test/nope/file.txt");
765 assert!(validate_write_path(bad).is_err());
766 }
767
768 #[test]
769 fn test_validate_write_path_relative_with_missing_parents() {
770 let nested = Path::new("target/ai-ctx-sanitize-new/subdir/new.txt");
771 assert!(validate_write_path(nested).is_ok());
772 }
773
774 #[test]
775 fn test_validate_dir_path() {
776 let tmp = std::env::temp_dir();
777 assert!(validate_dir_path(&tmp).is_ok());
778 }
779
780 #[test]
781 fn test_open_file_validated() {
782 let tmp = std::env::temp_dir().join("ai-ctx-san-open-file");
783 let _ = fs::remove_dir_all(&tmp);
784 fs::create_dir_all(&tmp).unwrap();
785 let test_file = tmp.join("test.txt");
786 fs::write(&test_file, "hello").unwrap();
787
788 let mut opened = open_file_validated(&test_file).unwrap();
789 let mut content = String::new();
790 use std::io::Read as _;
791 opened.read_to_string(&mut content).unwrap();
792 assert_eq!(content, "hello");
793
794 let _ = fs::remove_dir_all(&tmp);
795 }
796
797 #[test]
798 fn test_read_to_string_validated() {
799 let tmp = std::env::temp_dir().join("ai-ctx-san-read-string");
800 let _ = fs::remove_dir_all(&tmp);
801 fs::create_dir_all(&tmp).unwrap();
802 let test_file = tmp.join("test.txt");
803 fs::write(&test_file, "hello").unwrap();
804
805 let content = read_to_string_validated(&test_file).unwrap();
806 assert_eq!(content, "hello");
807
808 let _ = fs::remove_dir_all(&tmp);
809 }
810
811 #[test]
812 fn test_create_file_validated() {
813 let tmp = std::env::temp_dir().join("ai-ctx-san-create-file");
814 let _ = fs::remove_dir_all(&tmp);
815 fs::create_dir_all(&tmp).unwrap();
816 let test_file = tmp.join("test.txt");
817
818 let mut created = create_file_validated(&test_file).unwrap();
819 use std::io::Write as _;
820 created.write_all(b"hello").unwrap();
821 drop(created);
822
823 let content = fs::read_to_string(&test_file).unwrap();
824 assert_eq!(content, "hello");
825
826 let _ = fs::remove_dir_all(&tmp);
827 }
828
829 #[test]
830 fn test_read_dir_validated() {
831 let tmp = std::env::temp_dir().join("ai-ctx-san-read-dir");
832 let _ = fs::remove_dir_all(&tmp);
833 fs::create_dir_all(&tmp).unwrap();
834 fs::write(tmp.join("a.txt"), "a").unwrap();
835
836 let entries = read_dir_validated(&tmp)
837 .unwrap()
838 .filter_map(|entry| entry.ok())
839 .count();
840 assert_eq!(entries, 1);
841
842 let _ = fs::remove_dir_all(&tmp);
843 }
844
845 #[test]
846 fn test_sanitize_strips_nul_byte_and_warns() {
847 let sanitized = sanitize_chunk_content("abc\0def\0");
848 assert_eq!(sanitized.text, "abcdef");
849 assert_eq!(
850 sanitized.warnings,
851 vec![
852 ContentSanitizationWarning::NullByteStripped(3),
853 ContentSanitizationWarning::NullByteStripped(7),
854 ]
855 );
856 }
857
858 #[test]
859 fn test_sanitize_normalizes_crlf_to_lf() {
860 let sanitized = sanitize_chunk_content("one\r\ntwo\rthree\nfour");
861 assert_eq!(sanitized.text, "one\ntwo\nthree\nfour");
862 assert!(sanitized.warnings.is_empty());
863 }
864
865 #[test]
866 fn test_sanitize_preserves_unicode_emoji() {
867 let sanitized = sanitize_chunk_content("ship it 🚀");
868 assert_eq!(sanitized.text, "ship it 🚀");
869 assert!(sanitized.warnings.is_empty());
870 }
871
872 #[test]
873 fn test_sanitize_preserves_polish_diacritics_nfc() {
874 let input = "Zażółć gęślą jaźń";
875 let sanitized = sanitize_chunk_content(input);
876 assert_eq!(sanitized.text, input);
877 assert!(sanitized.warnings.is_empty());
878 }
879
880 #[test]
881 fn test_sanitize_bidi_override_warns_but_does_not_strip() {
882 let input = "safe \u{202E}txt";
883 let sanitized = sanitize_chunk_content(input);
884 assert_eq!(sanitized.text, input);
885 assert_eq!(
886 sanitized.warnings,
887 vec![ContentSanitizationWarning::BidiOverride('\u{202E}', 5)]
888 );
889 }
890
891 #[test]
892 fn test_sanitize_zero_width_warns_but_does_not_strip() {
893 let input = "zero\u{200B}width";
894 let sanitized = sanitize_chunk_content(input);
895 assert_eq!(sanitized.text, input);
896 assert_eq!(
897 sanitized.warnings,
898 vec![ContentSanitizationWarning::ZeroWidth('\u{200B}', 4)]
899 );
900 }
901
902 #[test]
903 fn test_safe_agent_name_valid() {
904 assert_eq!(safe_agent_name("claude").unwrap(), "claude");
905 assert_eq!(safe_agent_name("codex").unwrap(), "codex");
906 assert_eq!(safe_agent_name("gemini").unwrap(), "gemini");
907 assert_eq!(safe_agent_name("junie").unwrap(), "junie");
908 assert_eq!(safe_agent_name("codescribe").unwrap(), "codescribe");
909 assert_eq!(safe_agent_name("operator-md").unwrap(), "operator-md");
910 }
911
912 #[test]
913 fn test_safe_agent_name_rejects_unknown() {
914 assert!(safe_agent_name("rm").is_err());
915 assert!(safe_agent_name("bash").is_err());
916 assert!(safe_agent_name("claude; rm -rf /").is_err());
917 }
918
919 #[test]
920 fn test_safe_project_name_valid() {
921 assert!(safe_project_name("my-project").is_ok());
922 assert!(safe_project_name("lbrx-services").is_ok());
923 assert!(safe_project_name("CodeScribe").is_ok());
924 }
925}
926
927pub fn normalize_query(text: &str) -> String {
942 use unicode_normalization::UnicodeNormalization;
943 text.nfc()
944 .map(|c| match c {
945 'Ą' | 'ą' => 'a',
946 'Ć' | 'ć' => 'c',
947 'Ę' | 'ę' => 'e',
948 'Ł' | 'ł' => 'l',
949 'Ń' | 'ń' => 'n',
950 'Ó' | 'ó' => 'o',
951 'Ś' | 'ś' => 's',
952 'Ź' | 'ź' | 'Ż' | 'ż' => 'z',
953 _ => c,
954 })
955 .collect::<String>()
956 .to_lowercase()
957}
958
959const STABLE_SELF_ECHO_PATTERNS: &[&str] = &[
971 "aicx_search",
973 "aicx_read",
974 "aicx_rank",
975 "aicx_refs",
976 "aicx_store",
977 "/api/search/fuzzy",
979 "/api/search/semantic",
980 "/api/search/cross",
981 "/api/health",
982 "/api/regenerate",
983 "/api/status",
984];
985
986const MCP_JSONRPC_CATCH_ALL: &str = "\"jsonrpc\":\"2.0\"";
994
995const RETIRED_CLI_SUBCOMMANDS: &[&str] = &[
1000 "rank",
1002 "gemini", "junie",
1005];
1006
1007pub const CLI_SUBCOMMAND_NAMES: &[&str] = &[
1025 "all",
1026 "claims",
1027 "clarify",
1028 "claude",
1029 "codex",
1030 "config",
1031 "conversations",
1032 "corpus",
1033 "dashboard",
1034 "dashboard-serve",
1035 "doctor",
1036 "extract",
1037 "health",
1038 "index",
1039 "ingest",
1040 "init",
1041 "intents",
1042 "list",
1043 "migrate",
1044 "migrate-intent-schema",
1045 "read",
1046 "refs",
1047 "reports",
1048 "reports-extractor",
1049 "results",
1050 "search",
1051 "serve",
1052 "session",
1053 "sessions",
1054 "sources",
1055 "state",
1056 "steer",
1057 "store",
1058 "tail",
1059 "warmup",
1060 "wizard",
1061];
1062
1063fn build_self_echo_patterns(extra: &[String]) -> Vec<String> {
1071 let mut patterns: Vec<String> = Vec::with_capacity(
1072 STABLE_SELF_ECHO_PATTERNS.len()
1073 + CLI_SUBCOMMAND_NAMES.len()
1074 + RETIRED_CLI_SUBCOMMANDS.len()
1075 + extra.len()
1076 + 1,
1077 );
1078 for pat in STABLE_SELF_ECHO_PATTERNS {
1079 patterns.push((*pat).to_string());
1080 }
1081 patterns.push(MCP_JSONRPC_CATCH_ALL.to_string());
1082 for name in CLI_SUBCOMMAND_NAMES.iter().chain(RETIRED_CLI_SUBCOMMANDS) {
1083 patterns.push(format!("aicx {name}"));
1084 }
1085 for pat in extra {
1086 let trimmed = pat.trim();
1087 if !trimmed.is_empty() {
1088 patterns.push(trimmed.to_string());
1089 }
1090 }
1091 patterns
1092}
1093
1094static SELF_ECHO_PATTERNS_CACHE: std::sync::OnceLock<Vec<String>> = std::sync::OnceLock::new();
1098
1099fn active_self_echo_patterns() -> &'static [String] {
1103 SELF_ECHO_PATTERNS_CACHE.get_or_init(|| {
1104 let extras = load_extra_self_echo_patterns_from_config();
1105 build_self_echo_patterns(&extras)
1106 .into_iter()
1107 .map(|p| p.to_lowercase())
1108 .collect()
1109 })
1110}
1111
1112fn load_extra_self_echo_patterns_from_config() -> Vec<String> {
1124 #[derive(serde::Deserialize, Default)]
1125 struct ExtractionFile {
1126 #[serde(default)]
1127 extraction: Option<ExtractionSection>,
1128 }
1129 #[derive(serde::Deserialize, Default)]
1130 struct ExtractionSection {
1131 #[serde(default)]
1132 extra_self_echo_patterns: Vec<String>,
1133 }
1134
1135 let base = std::env::var_os("AICX_HOME")
1136 .map(PathBuf::from)
1137 .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".aicx")));
1138 let Some(base) = base else {
1139 return Vec::new();
1140 };
1141 let path = base.join("config.toml");
1142 let raw = match read_to_string_validated(&path) {
1143 Ok(raw) => raw,
1144 Err(_) => return Vec::new(),
1145 };
1146 let parsed: ExtractionFile = match toml::from_str(&raw) {
1147 Ok(parsed) => parsed,
1148 Err(_) => return Vec::new(),
1149 };
1150 parsed
1151 .extraction
1152 .map(|section| section.extra_self_echo_patterns)
1153 .unwrap_or_default()
1154 .into_iter()
1155 .map(|p| p.trim().to_string())
1156 .filter(|p| !p.is_empty())
1157 .collect()
1158}
1159
1160const AICX_READ_BEGIN: &str = "【aicx:read】";
1163const AICX_READ_END: &str = "【/aicx:read】";
1164
1165pub fn is_self_echo(message: &str) -> bool {
1172 let lines: Vec<&str> = message
1173 .lines()
1174 .map(str::trim)
1175 .filter(|l| !l.is_empty())
1176 .collect();
1177
1178 if lines.is_empty() {
1179 return false;
1180 }
1181
1182 let mut echo_lines = 0usize;
1183 let mut inside_aicx_block = false;
1184
1185 for line in &lines {
1186 if line.contains(AICX_READ_BEGIN) {
1187 inside_aicx_block = true;
1188 echo_lines += 1;
1189 continue;
1190 }
1191 if line.contains(AICX_READ_END) {
1192 inside_aicx_block = false;
1193 echo_lines += 1;
1194 continue;
1195 }
1196 if inside_aicx_block {
1197 echo_lines += 1;
1198 continue;
1199 }
1200 let lower = line.to_lowercase();
1201 if active_self_echo_patterns()
1202 .iter()
1203 .any(|pat| lower.contains(pat))
1204 {
1205 echo_lines += 1;
1206 }
1207 }
1208
1209 echo_lines > 0 && echo_lines * 2 > lines.len()
1211}
1212
1213pub fn filter_self_echo<T>(entries: Vec<T>, get_message: impl Fn(&T) -> &str) -> Vec<T> {
1215 entries
1216 .into_iter()
1217 .filter(|e| !is_self_echo(get_message(e)))
1218 .collect()
1219}
1220
1221#[cfg(test)]
1222mod echo_tests {
1223 use super::*;
1224
1225 #[test]
1226 fn test_normal_message_not_echo() {
1227 assert!(!is_self_echo("Fix the login regression in auth middleware"));
1228 assert!(!is_self_echo("Decision: use per-chunk scoring"));
1229 assert!(!is_self_echo("TODO: add tests for edge cases"));
1230 }
1231
1232 #[test]
1233 fn test_search_call_is_echo() {
1234 assert!(is_self_echo(
1235 r#"{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"aicx_search","arguments":{"query":"deploy vistacare"}}}"#
1236 ));
1237 }
1238
1239 #[test]
1240 fn test_api_call_is_echo() {
1241 assert!(is_self_echo(
1242 r#"curl -s "http://127.0.0.1:8033/api/search/fuzzy?q=deploy+vistacare&limit=3""#
1243 ));
1244 }
1245
1246 #[test]
1247 fn test_cli_self_invocation_is_echo() {
1248 assert!(is_self_echo("aicx all -H 24 --emit none"));
1249 assert!(is_self_echo("aicx store -H 24 --full-rescan"));
1250 assert!(is_self_echo("aicx store --hours 24"));
1251 assert!(is_self_echo("aicx rank -p ai-contexters -H 72 --strict"));
1252 assert!(is_self_echo(
1253 "aicx dashboard --generate-html -p ai-contexters -H 24"
1254 ));
1255 assert!(is_self_echo(
1256 "aicx reports --repo ai-contexters --workflow marbles"
1257 ));
1258 }
1259
1260 #[test]
1261 fn test_mention_in_larger_message_not_echo() {
1262 let msg = "We should add aicx_search to the MCP server.\n\
1264 The architecture looks clean.\n\
1265 Let's proceed with implementation.\n\
1266 Decision: expose 4 tools via rmcp.";
1267 assert!(!is_self_echo(msg));
1268 }
1269
1270 #[test]
1271 fn test_self_echo_exactly_half_is_not_majority() {
1272 let msg = "aicx all -H 24 --emit none\n\
1273 Decision: preserve real operator signal\n\
1274 aicx store -H 24 --full-rescan\n\
1275 Root cause: threshold was too wide";
1276 assert!(!is_self_echo(msg));
1277 }
1278
1279 #[test]
1280 fn test_self_echo_just_above_half_is_echo() {
1281 let msg = "aicx all -H 24 --emit none\n\
1282 Decision: preserve real operator signal\n\
1283 aicx store -H 24 --full-rescan\n\
1284 Root cause: threshold was too wide\n\
1285 aicx refs -H 24";
1286 assert!(is_self_echo(msg));
1287 }
1288
1289 #[test]
1290 fn test_self_echo_just_below_half_is_not_echo() {
1291 let msg = "aicx all -H 24 --emit none\n\
1292 Decision: preserve real operator signal\n\
1293 aicx store -H 24 --full-rescan\n\
1294 Root cause: threshold was too wide\n\
1295 Follow-up: add focused coverage";
1296 assert!(!is_self_echo(msg));
1297 }
1298
1299 #[test]
1311 fn test_state_subcommand_is_echo() {
1312 let msg = "aicx state --info\n\
1315 aicx state --reset\n\
1316 aicx state -p vetcoders/Vista";
1317 assert!(
1318 is_self_echo(msg),
1319 "`aicx state` invocations should be classified as self-echo"
1320 );
1321 }
1322
1323 #[test]
1324 fn test_doctor_subcommand_is_echo() {
1325 let msg = "aicx doctor --oracle\n\
1326 aicx doctor --rebuild-steer-index\n\
1327 aicx doctor --check-dedup";
1328 assert!(
1329 is_self_echo(msg),
1330 "`aicx doctor` invocations should be classified as self-echo"
1331 );
1332 }
1333
1334 #[test]
1346 fn test_build_self_echo_patterns_includes_config_extras() {
1347 let extras = vec![
1348 "custom-tool-name".to_string(),
1349 " /api/internal/foo ".to_string(), "".to_string(), ];
1352 let patterns = build_self_echo_patterns(&extras);
1353
1354 assert!(
1355 patterns.iter().any(|p| p == "custom-tool-name"),
1356 "non-empty extra pattern must appear verbatim in the materialized list"
1357 );
1358 assert!(
1359 patterns.iter().any(|p| p == "/api/internal/foo"),
1360 "extra patterns must be trimmed of surrounding whitespace"
1361 );
1362 assert!(
1363 !patterns.iter().any(|p| p.is_empty()),
1364 "empty extras must be filtered out"
1365 );
1366
1367 assert!(patterns.iter().any(|p| p == "aicx_search"));
1369 assert!(patterns.iter().any(|p| p == "aicx doctor"));
1370 assert!(patterns.iter().any(|p| p == "aicx rank"));
1371 assert!(patterns.iter().any(|p| p == MCP_JSONRPC_CATCH_ALL));
1372 }
1373
1374 #[test]
1375 fn test_build_self_echo_patterns_empty_extras_matches_baseline() {
1376 let baseline = build_self_echo_patterns(&[]);
1379 assert!(
1380 baseline.iter().any(|p| p == "aicx_search"),
1381 "MCP tool names must survive an empty extras list"
1382 );
1383 assert!(
1384 baseline.iter().any(|p| p == "aicx doctor"),
1385 "CLI subcommand patterns must survive an empty extras list"
1386 );
1387 }
1388
1389 #[test]
1390 fn test_load_extra_self_echo_patterns_from_config_reads_extraction_section() {
1391 use std::fs;
1392 use std::sync::atomic::{AtomicUsize, Ordering};
1393
1394 static UNIQ: AtomicUsize = AtomicUsize::new(0);
1395 let dir = std::env::temp_dir().join(format!(
1396 "aicx-extraction-config-{}-{}",
1397 std::process::id(),
1398 UNIQ.fetch_add(1, Ordering::SeqCst)
1399 ));
1400 fs::create_dir_all(&dir).unwrap();
1401 fs::write(
1402 dir.join("config.toml"),
1403 r#"
1404[extraction]
1405extra_self_echo_patterns = [
1406 "operator-marker-alpha",
1407 " /api/operator/beta ",
1408 "",
1409]
1410"#,
1411 )
1412 .unwrap();
1413
1414 let prev = std::env::var_os("AICX_HOME");
1418 unsafe {
1423 std::env::set_var("AICX_HOME", &dir);
1424 }
1425 let loaded = load_extra_self_echo_patterns_from_config();
1426 unsafe {
1427 match prev {
1428 Some(v) => std::env::set_var("AICX_HOME", v),
1429 None => std::env::remove_var("AICX_HOME"),
1430 }
1431 }
1432
1433 assert!(
1434 loaded.iter().any(|p| p == "operator-marker-alpha"),
1435 "loader must surface verbatim operator-supplied pattern; got {loaded:?}"
1436 );
1437 assert!(
1438 loaded.iter().any(|p| p == "/api/operator/beta"),
1439 "loader must trim whitespace; got {loaded:?}"
1440 );
1441 assert!(
1442 !loaded.iter().any(|p| p.is_empty()),
1443 "loader must drop empty entries; got {loaded:?}"
1444 );
1445
1446 let _ = fs::remove_dir_all(&dir);
1447 }
1448
1449 #[test]
1450 fn test_load_extra_self_echo_patterns_returns_empty_when_no_config() {
1451 use std::sync::atomic::{AtomicUsize, Ordering};
1452
1453 static UNIQ: AtomicUsize = AtomicUsize::new(0);
1454 let nonexistent = std::env::temp_dir().join(format!(
1455 "aicx-extraction-config-nope-{}-{}",
1456 std::process::id(),
1457 UNIQ.fetch_add(1, Ordering::SeqCst)
1458 ));
1459 let prev = std::env::var_os("AICX_HOME");
1462 unsafe {
1463 std::env::set_var("AICX_HOME", &nonexistent);
1464 }
1465 let loaded = load_extra_self_echo_patterns_from_config();
1466 unsafe {
1467 match prev {
1468 Some(v) => std::env::set_var("AICX_HOME", v),
1469 None => std::env::remove_var("AICX_HOME"),
1470 }
1471 }
1472
1473 assert!(
1474 loaded.is_empty(),
1475 "missing config must yield empty extras, not panic; got {loaded:?}"
1476 );
1477 }
1478
1479 #[test]
1480 fn test_mcp_jsonrpc_catch_all_matches_unknown_method() {
1481 assert!(
1488 is_self_echo(
1489 r#"{"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":7}}"#
1490 ),
1491 "unknown JSON-RPC method should be caught by the generic catch-all"
1492 );
1493 assert!(
1494 is_self_echo(r#"{"jsonrpc":"2.0","method":"resources/list","params":{}}"#),
1495 "resources/list JSON-RPC must be filtered as self-echo"
1496 );
1497 assert!(
1498 is_self_echo(
1499 r#"{"jsonrpc":"2.0","id":42,"result":{"contents":[{"uri":"aicx://chunk/abc"}]}}"#
1500 ),
1501 "JSON-RPC result envelope (no method name at all) must still classify as self-echo"
1502 );
1503 }
1504
1505 #[test]
1506 fn test_corpus_index_warmup_wizard_config_subcommands_are_echo() {
1507 for line in [
1511 "aicx corpus --audit",
1512 "aicx index --dry-run",
1513 "aicx warmup",
1514 "aicx wizard",
1515 "aicx config show",
1516 "aicx steer --run-id abc",
1517 "aicx tail --follow",
1518 "aicx ingest --source loct-context-pack",
1519 "aicx migrate --dry-run",
1520 "aicx sources audit",
1521 "aicx serve --transport stdio",
1522 "aicx extract --session abc --agent claude",
1523 "aicx read /some/path",
1524 "aicx search hello",
1525 "aicx refs -H 24",
1526 "aicx reports --workflow marbles",
1527 "aicx intents -H 720",
1528 "aicx conversations --out-dir /tmp",
1529 "aicx health",
1530 "aicx list",
1531 "aicx claude -H 24",
1532 "aicx codex -H 24",
1533 "aicx all -H 24",
1534 "aicx store -H 24",
1535 ] {
1536 assert!(
1537 is_self_echo(line),
1538 "`{line}` should be classified as self-echo (Commands variant missing from pattern list?)"
1539 );
1540 }
1541 }
1542}
1543
1544#[cfg(test)]
1545mod normalize_tests {
1546 use super::*;
1547
1548 #[test]
1549 fn test_normalize_query_strips_diacritics() {
1550 assert_eq!(normalize_query("wdrożenie"), "wdrozenie");
1551 assert_eq!(normalize_query("źródło ŁĄCZNOŚCI"), "zrodlo lacznosci");
1552 assert_eq!(normalize_query("Deploy Vista"), "deploy vista");
1553 assert_eq!(normalize_query("ąćęłńóśźż"), "acelnoszz");
1554 }
1555
1556 #[test]
1557 fn test_normalize_query_unifies_nfc_and_nfd_inputs() {
1558 use unicode_normalization::UnicodeNormalization;
1562 let composed = "źródło";
1563 let decomposed: String = composed.nfd().collect();
1564 assert_ne!(
1565 composed, decomposed,
1566 "test pre-condition: NFC and NFD forms must differ at the byte level"
1567 );
1568 assert_eq!(
1569 normalize_query(composed),
1570 normalize_query(&decomposed),
1571 "NFC pre-pass must collapse pre-composed and decomposed diacritics"
1572 );
1573 assert_eq!(normalize_query(&decomposed), "zrodlo");
1574 }
1575
1576 #[test]
1577 fn test_safe_project_name_rejects_bad() {
1578 assert!(safe_project_name("../etc").is_err());
1579 assert!(safe_project_name("foo/bar").is_err());
1580 assert!(safe_project_name("").is_err());
1581 assert!(safe_project_name("foo\0bar").is_err());
1582 }
1583}