Skip to main content

truth_mirror/
config.rs

1//! truth-mirror configuration: adversarial pairs, reasoning effort, gates,
2//! ground-truth, trajectory, and enforcement.
3
4use std::{
5    collections::BTreeMap,
6    fs, io,
7    path::{Path, PathBuf},
8};
9
10use serde::{Deserialize, Serialize};
11use thiserror::Error;
12
13pub const DEFAULT_STATE_DIR: &str = ".truth";
14pub const LEGACY_STATE_DIR: &str = ".truth-mirror";
15pub const MAX_PETITION_BATCH_SIZE: usize = 32;
16
17/// Hard safety ceiling for concurrent provider processes.
18pub const MAX_CONCURRENT_REVIEW_RUNS: usize = 4;
19pub const DEFAULT_MEMORY_SKILL_BLOCKED_PATTERNS: &[&str] = &[
20    "BEGIN SYSTEM PROMPT",
21    "ignore previous instructions",
22    "api_key",
23    "secret_key",
24    "secret:",
25    "secret=",
26    "token:",
27    "token=",
28    "password:",
29    "password=",
30    "credential:",
31    "credential=",
32    "private_key",
33    "aws_secret_access_key",
34];
35
36/// Reasoning effort (`C`). Highest is `Xhigh`.
37#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize, clap::ValueEnum)]
38#[serde(rename_all = "lowercase")]
39#[value(rename_all = "lowercase")]
40pub enum Effort {
41    Minimal,
42    Low,
43    Medium,
44    High,
45    #[default]
46    Xhigh,
47}
48
49impl Effort {
50    pub fn as_str(self) -> &'static str {
51        match self {
52            Effort::Minimal => "minimal",
53            Effort::Low => "low",
54            Effort::Medium => "medium",
55            Effort::High => "high",
56            Effort::Xhigh => "xhigh",
57        }
58    }
59
60    /// The highest supported effort — the default reviewer aggressiveness.
61    pub fn highest() -> Self {
62        Effort::Xhigh
63    }
64
65    /// Value accepted by Claude's `--effort`. Verified: claude takes
66    /// `low|medium|high|xhigh|max` and has no `minimal`, so `Minimal` clamps to
67    /// `low`. (Codex and Pi both accept `minimal`.)
68    pub fn claude_value(self) -> &'static str {
69        match self {
70            Effort::Minimal => "low",
71            other => other.as_str(),
72        }
73    }
74}
75
76/// A concrete reviewer/arbiter selection: harness + model + reasoning effort.
77#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
78pub struct HarnessSelection {
79    pub harness: String,
80    pub model: String,
81    #[serde(default)]
82    pub effort: Effort,
83}
84
85impl HarnessSelection {
86    pub fn new(harness: impl Into<String>, model: impl Into<String>, effort: Effort) -> Self {
87        Self {
88            harness: harness.into(),
89            model: model.into(),
90            effort,
91        }
92    }
93}
94
95/// The opposed reviewer (and optional second-pass arbiter) for one writer harness.
96#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
97pub struct AdversarialPair {
98    pub reviewer: HarnessSelection,
99    #[serde(default)]
100    pub arbiter: Option<HarnessSelection>,
101}
102
103#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
104pub struct TruthMirrorConfig {
105    #[serde(default = "default_ledger_dir")]
106    pub ledger_dir: String,
107    #[serde(default)]
108    pub allow_same_model: bool,
109    /// Writer harness assumed when none is provided on the CLI or commit trailer.
110    #[serde(default = "default_writer")]
111    pub default_writer: String,
112    /// Adversarial pairs keyed by writer harness (lowercase). Empty in the parsed
113    /// form when `[pairs]` is absent; `normalize` fills defaults / folds legacy.
114    #[serde(default)]
115    pub pairs: BTreeMap<String, AdversarialPair>,
116    #[serde(default)]
117    pub strict: StrictConfig,
118    #[serde(default)]
119    pub gates: GatesConfig,
120    #[serde(default)]
121    pub ground_truth: GroundTruthConfig,
122    #[serde(default)]
123    pub history: HistoryConfig,
124    #[serde(default)]
125    pub enforcement: EnforcementConfig,
126    #[serde(default)]
127    pub skills: SkillsConfig,
128    #[serde(default)]
129    pub memory_skill: MemorySkillConfig,
130    /// Reviewer subprocess settings (per-review wall-clock timeout).
131    #[serde(default)]
132    pub reviewer: ReviewerPolicyConfig,
133    /// Legacy `[review]` block, folded into `pairs` on load for back-compat.
134    #[serde(default, skip_serializing_if = "Option::is_none")]
135    pub review: Option<LegacyReview>,
136}
137
138impl Default for TruthMirrorConfig {
139    fn default() -> Self {
140        Self {
141            ledger_dir: default_ledger_dir(),
142            allow_same_model: false,
143            default_writer: default_writer(),
144            pairs: default_pairs(),
145            strict: StrictConfig::default(),
146            gates: GatesConfig::default(),
147            ground_truth: GroundTruthConfig::default(),
148            history: HistoryConfig::default(),
149            enforcement: EnforcementConfig::default(),
150            skills: SkillsConfig::default(),
151            memory_skill: MemorySkillConfig::default(),
152            reviewer: ReviewerPolicyConfig::default(),
153            review: None,
154        }
155    }
156}
157
158impl TruthMirrorConfig {
159    pub fn load_for_cli(
160        explicit_path: Option<&Path>,
161        state_dir: &Path,
162    ) -> Result<Self, ConfigError> {
163        let path = explicit_path.map_or_else(|| Self::default_path(state_dir), PathBuf::from);
164        Self::load_or_default(path)
165    }
166
167    pub fn load_or_default(path: impl Into<PathBuf>) -> Result<Self, ConfigError> {
168        let path = path.into();
169        match fs::read_to_string(&path) {
170            Ok(contents) => Self::from_toml_str(&path, &contents),
171            Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(Self::default()),
172            Err(source) => Err(ConfigError::Read { path, source }),
173        }
174    }
175
176    pub fn from_toml_str(path: &Path, contents: &str) -> Result<Self, ConfigError> {
177        let mut config: Self = toml::from_str(contents).map_err(|source| ConfigError::Parse {
178            path: path.to_path_buf(),
179            source,
180        })?;
181        config.normalize();
182        config.validate()?;
183        Ok(config)
184    }
185
186    /// Resolve the effective `pairs`: explicit `[pairs]` win; a legacy `[review]`
187    /// block folds in as an OVERRIDE for its writer (on top of defaults so other
188    /// writers still resolve); with neither, all writers get default pairs.
189    fn normalize(&mut self) {
190        // Lowercase explicit pair keys FIRST so every comparison below is
191        // case-insensitive (matching `pair_for`). Otherwise a `[pairs.CODEX]` could
192        // be clobbered by a legacy `[review]` folded under `codex`.
193        let lowered: BTreeMap<String, AdversarialPair> = std::mem::take(&mut self.pairs)
194            .into_iter()
195            .map(|(key, value)| (key.trim().to_ascii_lowercase(), value))
196            .collect();
197        self.pairs = lowered;
198
199        let had_explicit_pairs = !self.pairs.is_empty();
200        let review = self.review.take();
201
202        if !had_explicit_pairs && review.is_some() {
203            // Legacy-only config: seed defaults so non-legacy writers still resolve.
204            self.pairs = default_pairs();
205        }
206
207        if let Some(review) = review {
208            let writer = review.watched.harness.trim().to_ascii_lowercase();
209            let pair = AdversarialPair {
210                reviewer: HarnessSelection::new(
211                    review.reviewer.harness,
212                    review.reviewer.model,
213                    Effort::highest(),
214                ),
215                arbiter: None,
216            };
217            if had_explicit_pairs {
218                // Explicit `[pairs]` win: legacy only fills a writer they omit.
219                self.pairs.entry(writer).or_insert(pair);
220            } else {
221                // Legacy-only config: override the seeded default for this writer.
222                self.pairs.insert(writer, pair);
223            }
224        }
225
226        if self.pairs.is_empty() {
227            self.pairs = default_pairs();
228        }
229    }
230
231    fn validate(&self) -> Result<(), ConfigError> {
232        for (writer, pair) in &self.pairs {
233            if let Some(arbiter) = &pair.arbiter
234                && normalized_model(&arbiter.model) == normalized_model(&pair.reviewer.model)
235            {
236                return Err(ConfigError::ArbiterNotDistinct {
237                    writer: writer.clone(),
238                });
239            }
240        }
241        if !self.memory_skill.scan.entropy_threshold.is_finite()
242            || self.memory_skill.scan.entropy_threshold <= 0.0
243        {
244            return Err(ConfigError::InvalidMemorySkillEntropyThreshold {
245                value: self.memory_skill.scan.entropy_threshold,
246            });
247        }
248        if !self.memory_skill.signals.similarity_threshold.is_finite()
249            || !(0.0..=1.0).contains(&self.memory_skill.signals.similarity_threshold)
250        {
251            return Err(ConfigError::InvalidMemorySkillSimilarityThreshold {
252                value: self.memory_skill.signals.similarity_threshold,
253            });
254        }
255        if !(1..=MAX_PETITION_BATCH_SIZE).contains(&self.reviewer.max_petition_batch_size) {
256            return Err(ConfigError::InvalidPetitionBatchSize {
257                value: self.reviewer.max_petition_batch_size,
258            });
259        }
260        if !(1..=MAX_CONCURRENT_REVIEW_RUNS).contains(&self.reviewer.max_concurrent_runs) {
261            return Err(ConfigError::InvalidConcurrentReviewRuns {
262                value: self.reviewer.max_concurrent_runs,
263            });
264        }
265
266        if self.memory_skill.max_skill_bytes == 0 {
267            return Err(ConfigError::InvalidMemorySkillMaxSkillBytes);
268        }
269        if self.memory_skill.scan.secret_detector_timeout_seconds == 0 {
270            return Err(ConfigError::InvalidMemorySkillSecretDetectorTimeout);
271        }
272        Ok(())
273    }
274
275    /// The adversarial pair for a writer harness (case-insensitive).
276    pub fn pair_for(&self, writer_harness: &str) -> Option<&AdversarialPair> {
277        self.pairs.get(&writer_harness.trim().to_ascii_lowercase())
278    }
279
280    pub fn default_path(state_dir: &Path) -> PathBuf {
281        state_dir.join("config.toml")
282    }
283}
284
285/// Default per-review wall-clock timeout: 20 minutes. Two days of field use
286/// showed wedged reviewer subprocesses running 1–7 hours with the queue frozen
287/// behind them; a healthy adversarial review takes minutes, so 20 minutes is
288/// comfortably past legitimate work and short enough to unstick the queue.
289pub const DEFAULT_REVIEWER_TIMEOUT_SECS: u64 = 20 * 60;
290
291/// Reviewer queue policies, subprocess settings, and concurrent scheduling.
292#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
293#[serde(default)]
294pub struct ReviewerPolicyConfig {
295    /// Opt-in compatibility boundary: false retains one reviewer invocation per petition.
296    pub batch_petitions: bool,
297    pub max_petition_batch_size: usize,
298    /// Per-review wall-clock timeout in seconds. A reviewer subprocess that
299    /// outlives this is killed and the run recorded as failed with a timeout
300    /// reason, so one wedged reviewer never freezes the queue. `0` disables
301    /// the timeout.
302    pub timeout_secs: u64,
303    /// Maximum number of independent reviewer processes in one drain wave.
304    /// `1` preserves the historical serial scheduler.
305    pub max_concurrent_runs: usize,
306}
307
308impl Default for ReviewerPolicyConfig {
309    fn default() -> Self {
310        Self {
311            batch_petitions: false,
312            max_petition_batch_size: MAX_PETITION_BATCH_SIZE,
313            timeout_secs: DEFAULT_REVIEWER_TIMEOUT_SECS,
314            max_concurrent_runs: 1,
315        }
316    }
317}
318
319impl ReviewerPolicyConfig {
320    /// The configured timeout as a duration, or `None` when disabled (`0`).
321    pub fn timeout(&self) -> Option<std::time::Duration> {
322        (self.timeout_secs > 0).then(|| std::time::Duration::from_secs(self.timeout_secs))
323    }
324}
325
326/// Legacy single watched/reviewer pair (pre-adversarial-pairs config).
327#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
328pub struct LegacyReview {
329    pub watched: LegacyModel,
330    pub reviewer: LegacyModel,
331}
332
333#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
334pub struct LegacyModel {
335    pub harness: String,
336    pub model: String,
337}
338
339/// Strict goal-loop thresholds. `N == 0` disables that stop condition.
340#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
341#[serde(default)]
342pub struct StrictConfig {
343    pub stop_after_lies: u32,
344    pub stop_after_fuckups: u32,
345    pub max_passes: u32,
346}
347
348impl Default for StrictConfig {
349    fn default() -> Self {
350        Self {
351            stop_after_lies: 1,
352            stop_after_fuckups: 3,
353            max_passes: 3,
354        }
355    }
356}
357
358impl StrictConfig {
359    pub fn goal_policy(
360        &self,
361        lies_override: Option<u32>,
362        fuckups_override: Option<u32>,
363    ) -> crate::reviewer::StrictGoalPolicy {
364        crate::reviewer::StrictGoalPolicy {
365            stop_after_lies: lies_override.unwrap_or(self.stop_after_lies),
366            stop_after_fuckups: fuckups_override.unwrap_or(self.stop_after_fuckups),
367        }
368    }
369}
370
371/// Deterministic-gate configuration: banned diff sentinels, evidence patterns,
372/// and diff paths excluded from marker scanning.
373#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
374#[serde(default)]
375pub struct GatesConfig {
376    pub fake_markers: Vec<String>,
377    pub evidence_patterns: Vec<String>,
378    pub marker_ignore_paths: Vec<String>,
379}
380
381impl Default for GatesConfig {
382    fn default() -> Self {
383        Self {
384            fake_markers: strs(crate::claim::DEFAULT_FAKE_MARKERS),
385            evidence_patterns: strs(crate::claim::DEFAULT_EVIDENCE_PATTERNS),
386            marker_ignore_paths: strs(crate::claim::DEFAULT_MARKER_IGNORE_PATHS),
387        }
388    }
389}
390
391impl GatesConfig {
392    /// The resolved gate policy: config values plus built-in defaults for any
393    /// list left empty, so an empty config never silently disables a gate.
394    pub fn to_policy(&self) -> crate::claim::GatePolicy {
395        crate::claim::GatePolicy {
396            fake_markers: union_defaults(&self.fake_markers, crate::claim::DEFAULT_FAKE_MARKERS),
397            evidence_patterns: union_defaults(
398                &self.evidence_patterns,
399                crate::claim::DEFAULT_EVIDENCE_PATTERNS,
400            ),
401            marker_ignore_paths: union_defaults(
402                &self.marker_ignore_paths,
403                crate::claim::DEFAULT_MARKER_IGNORE_PATHS,
404            ),
405        }
406    }
407}
408
409fn strs(values: &[&str]) -> Vec<String> {
410    values.iter().map(|value| (*value).to_owned()).collect()
411}
412
413/// Built-in defaults ALWAYS apply; configured values are additive. A repo can add
414/// its own banned tokens or evidence patterns but cannot silently disable the
415/// deterministic anti-lie defaults by setting a shorter list.
416fn union_defaults(values: &[String], defaults: &[&str]) -> Vec<String> {
417    let mut out: Vec<String> = strs(defaults);
418    for value in values {
419        if !out.iter().any(|existing| existing == value) {
420            out.push(value.clone());
421        }
422    }
423    out
424}
425
426/// Ground-truth constraint loading.
427#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
428#[serde(default)]
429pub struct GroundTruthConfig {
430    pub enabled: bool,
431    pub file_names: Vec<String>,
432    pub include_openspec_specs: bool,
433    pub max_bytes: usize,
434}
435
436impl Default for GroundTruthConfig {
437    fn default() -> Self {
438        Self {
439            enabled: true,
440            file_names: ["TRUTH.md", "AGENTS.md", "CLAUDE.md"]
441                .iter()
442                .map(|name| (*name).to_owned())
443                .collect(),
444            include_openspec_specs: true,
445            max_bytes: 20_000,
446        }
447    }
448}
449
450/// Conversation-trajectory window.
451#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
452#[serde(default)]
453pub struct HistoryConfig {
454    pub window_user: usize,
455    pub window_agent: usize,
456    pub max_bytes: usize,
457    /// Optional repo-relative JSONL transcript (`{role,text}` per line). When
458    /// unset or missing, recent commits are used as the trajectory proxy.
459    pub transcript_path: Option<String>,
460}
461
462impl Default for HistoryConfig {
463    fn default() -> Self {
464        Self {
465            window_user: 3,
466            window_agent: 10,
467            max_bytes: 12_000,
468            transcript_path: None,
469        }
470    }
471}
472
473/// Enforcement escalation thresholds. `0` disables a condition.
474#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
475#[serde(default)]
476pub struct EnforcementConfig {
477    pub block_tools_after_unresolved: u32,
478    pub block_tools_after_secs: u64,
479}
480
481impl EnforcementConfig {
482    pub fn is_enabled(&self) -> bool {
483        self.block_tools_after_unresolved > 0 || self.block_tools_after_secs > 0
484    }
485}
486
487#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
488#[serde(default)]
489pub struct SkillsConfig {
490    pub enabled: bool,
491}
492
493impl Default for SkillsConfig {
494    fn default() -> Self {
495        Self { enabled: true }
496    }
497}
498
499#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
500#[serde(rename_all = "lowercase")]
501pub enum MemorySkillMode {
502    Off,
503    Suggest,
504    #[default]
505    Stage,
506}
507
508#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
509#[serde(rename_all = "snake_case")]
510pub enum MemorySkillCandidateKind {
511    HowToSkill,
512    AntiPatternSkill,
513    RemediationSkill,
514}
515
516impl MemorySkillCandidateKind {
517    pub fn as_str(self) -> &'static str {
518        match self {
519            Self::HowToSkill => "how_to_skill",
520            Self::AntiPatternSkill => "anti_pattern_skill",
521            Self::RemediationSkill => "remediation_skill",
522        }
523    }
524}
525
526impl std::fmt::Display for MemorySkillCandidateKind {
527    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
528        formatter.write_str(self.as_str())
529    }
530}
531
532#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
533#[serde(default)]
534pub struct MemorySkillConfig {
535    pub enabled: bool,
536    pub mode: MemorySkillMode,
537    pub candidate_dir: String,
538    pub approved_dir: String,
539    pub approved_slug_prefix: String,
540    pub toolbox_delivery_registry: String,
541    pub allow_global_writes: bool,
542    pub require_claim_evidence: bool,
543    pub max_candidates_per_commit: usize,
544    pub max_skill_bytes: usize,
545    pub pre_push_blocks_pending: bool,
546    /// Historical config key: when true, `memory_skill::scan_text` rejects the
547    /// whole candidate on secret matches; it does not redact matched text in
548    /// place. Scanning is fully disabled only when this is false and
549    /// `memory_skill.scan.secret_detection = "off"`.
550    pub redact_secrets: bool,
551    pub signals: MemorySkillSignalsConfig,
552    pub review: MemorySkillReviewConfig,
553    pub scan: MemorySkillScanConfig,
554}
555
556impl Default for MemorySkillConfig {
557    fn default() -> Self {
558        Self {
559            enabled: true,
560            mode: MemorySkillMode::Stage,
561            candidate_dir: format!("{DEFAULT_STATE_DIR}/skills/candidates"),
562            approved_dir: ".agents/skills".to_owned(),
563            approved_slug_prefix: "generated-".to_owned(),
564            toolbox_delivery_registry: String::new(),
565            allow_global_writes: false,
566            require_claim_evidence: true,
567            max_candidates_per_commit: 1,
568            max_skill_bytes: 12_000,
569            pre_push_blocks_pending: false,
570            redact_secrets: true,
571            signals: MemorySkillSignalsConfig::default(),
572            review: MemorySkillReviewConfig::default(),
573            scan: MemorySkillScanConfig::default(),
574        }
575    }
576}
577
578impl MemorySkillConfig {
579    pub fn effective_enabled(&self, skills: &SkillsConfig) -> bool {
580        skills.enabled && self.enabled && self.mode != MemorySkillMode::Off
581    }
582}
583
584#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
585#[serde(default)]
586pub struct MemorySkillSignalsConfig {
587    pub similarity_threshold: f64,
588    pub min_occurrences: usize,
589    pub require_reusable_procedure: bool,
590    pub capture_passes_as_how_to: bool,
591    pub capture_rejections_as_remediation: bool,
592    pub capture_rejections_as_antipattern: bool,
593    pub rejection_precedence: Vec<MemorySkillCandidateKind>,
594}
595
596impl Default for MemorySkillSignalsConfig {
597    fn default() -> Self {
598        Self {
599            similarity_threshold: 0.55,
600            min_occurrences: 2,
601            require_reusable_procedure: true,
602            capture_passes_as_how_to: true,
603            capture_rejections_as_remediation: true,
604            capture_rejections_as_antipattern: true,
605            rejection_precedence: vec![
606                MemorySkillCandidateKind::AntiPatternSkill,
607                MemorySkillCandidateKind::RemediationSkill,
608            ],
609        }
610    }
611}
612
613#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
614#[serde(default)]
615pub struct MemorySkillReviewConfig {
616    pub require_adversarial_review: bool,
617    pub require_reviewed_ledger_entry: bool,
618    pub require_pass_for_how_to: bool,
619    pub require_structured_findings_checked: bool,
620    pub reject_without_ledger_entry: bool,
621}
622
623impl Default for MemorySkillReviewConfig {
624    fn default() -> Self {
625        Self {
626            require_adversarial_review: true,
627            require_reviewed_ledger_entry: true,
628            require_pass_for_how_to: true,
629            require_structured_findings_checked: true,
630            reject_without_ledger_entry: true,
631        }
632    }
633}
634
635#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
636#[serde(default)]
637pub struct MemorySkillScanConfig {
638    pub secret_detection: SecretDetectionMode,
639    /// Optional scanner command. A non-zero exit is treated as a detected
640    /// finding; best-effort mode only downgrades operational failures.
641    pub secret_detector: String,
642    pub allow_secret_detector_command: bool,
643    pub secret_detector_timeout_seconds: u64,
644    pub entropy_threshold: f64,
645    pub blocked_patterns: Vec<String>,
646    pub blocked_patterns_case_insensitive: bool,
647}
648
649impl Default for MemorySkillScanConfig {
650    fn default() -> Self {
651        Self {
652            secret_detection: SecretDetectionMode::Required,
653            secret_detector: String::new(),
654            allow_secret_detector_command: false,
655            secret_detector_timeout_seconds: 10,
656            entropy_threshold: 4.5,
657            blocked_patterns: Vec::new(),
658            blocked_patterns_case_insensitive: true,
659        }
660    }
661}
662
663impl MemorySkillScanConfig {
664    pub fn effective_blocked_patterns(&self) -> Vec<String> {
665        let mut patterns = Vec::new();
666        for pattern in DEFAULT_MEMORY_SKILL_BLOCKED_PATTERNS {
667            push_unique_pattern(
668                &mut patterns,
669                pattern,
670                self.blocked_patterns_case_insensitive,
671            );
672        }
673        for pattern in &self.blocked_patterns {
674            push_unique_pattern(
675                &mut patterns,
676                pattern,
677                self.blocked_patterns_case_insensitive,
678            );
679        }
680        patterns
681    }
682}
683
684fn push_unique_pattern(patterns: &mut Vec<String>, pattern: &str, case_insensitive: bool) {
685    let pattern = pattern.trim();
686    if pattern.is_empty()
687        || patterns.iter().any(|existing| {
688            if case_insensitive {
689                existing.eq_ignore_ascii_case(pattern)
690            } else {
691                existing == pattern
692            }
693        })
694    {
695        return;
696    }
697    patterns.push(pattern.to_owned());
698}
699
700#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
701#[serde(rename_all = "kebab-case")]
702pub enum SecretDetectionMode {
703    #[default]
704    Required,
705    BestEffort,
706    Off,
707}
708
709#[derive(Debug, Error)]
710pub enum ConfigError {
711    #[error("failed to read config {path}: {source}")]
712    Read {
713        path: PathBuf,
714        #[source]
715        source: io::Error,
716    },
717    #[error("failed to parse config {path}: {source}")]
718    Parse {
719        path: PathBuf,
720        #[source]
721        source: toml::de::Error,
722    },
723    #[error("pair for writer {writer:?} has an arbiter model equal to the reviewer model")]
724    ArbiterNotDistinct { writer: String },
725    #[error("memory_skill.scan.entropy_threshold must be finite and greater than 0.0, got {value}")]
726    InvalidMemorySkillEntropyThreshold { value: f64 },
727    #[error(
728        "memory_skill.signals.similarity_threshold must be finite and between 0.0 and 1.0, got {value}"
729    )]
730    InvalidMemorySkillSimilarityThreshold { value: f64 },
731    #[error(
732        "reviewer.max_petition_batch_size must be between 1 and {MAX_PETITION_BATCH_SIZE}, got {value}"
733    )]
734    InvalidPetitionBatchSize { value: usize },
735    #[error(
736        "reviewer.max_concurrent_runs must be between 1 and {MAX_CONCURRENT_REVIEW_RUNS}, got {value}"
737    )]
738    InvalidConcurrentReviewRuns { value: usize },
739    #[error("memory_skill.max_skill_bytes must be greater than 0")]
740    InvalidMemorySkillMaxSkillBytes,
741    #[error("memory_skill.scan.secret_detector_timeout_seconds must be greater than 0")]
742    InvalidMemorySkillSecretDetectorTimeout,
743}
744
745fn default_ledger_dir() -> String {
746    DEFAULT_STATE_DIR.to_owned()
747}
748
749fn default_writer() -> String {
750    "codex".to_owned()
751}
752
753/// Sensible opposed pairs so a repo with no `[pairs]` still reviews adversarially.
754fn default_pairs() -> BTreeMap<String, AdversarialPair> {
755    let mut pairs = BTreeMap::new();
756    pairs.insert(
757        "codex".to_owned(),
758        AdversarialPair {
759            reviewer: HarnessSelection::new("claude", "claude-opus-4-8", Effort::highest()),
760            arbiter: Some(HarnessSelection::new(
761                "pi",
762                "openai-codex/gpt-5.5",
763                Effort::highest(),
764            )),
765        },
766    );
767    pairs.insert(
768        "claude".to_owned(),
769        AdversarialPair {
770            reviewer: HarnessSelection::new("codex", "gpt-5.5", Effort::highest()),
771            // Arbiter must be a third distinct model. The reviewer is codex/gpt-5.5;
772            // using pi/openai-codex/gpt-5.5 as arbiter would be the SAME underlying
773            // model after provider-prefix stripping, violating the distinct-model rule.
774            // Gemini (gemini-2.5-pro) is a genuinely different family and provider,
775            // and InvocationPlan::for_harness supports ReviewerHarness::Gemini.
776            arbiter: Some(HarnessSelection::new(
777                "gemini",
778                "gemini-2.5-pro",
779                Effort::highest(),
780            )),
781        },
782    );
783    pairs.insert(
784        "pi".to_owned(),
785        AdversarialPair {
786            reviewer: HarnessSelection::new("codex", "gpt-5.5", Effort::highest()),
787            arbiter: Some(HarnessSelection::new(
788                "claude",
789                "claude-opus-4-8",
790                Effort::highest(),
791            )),
792        },
793    );
794    // Grok is a watched agent surface, not a reviewer harness. Oppose with Claude
795    // review + Codex arbiter so model/provider families stay distinct by default.
796    pairs.insert(
797        "grok".to_owned(),
798        AdversarialPair {
799            reviewer: HarnessSelection::new("claude", "claude-opus-4-8", Effort::highest()),
800            arbiter: Some(HarnessSelection::new("codex", "gpt-5.5", Effort::highest())),
801        },
802    );
803    pairs
804}
805
806/// Normalise a model identifier for opposition comparisons.
807///
808/// Strips the provider prefix (everything up to and including the last `/`)
809/// so `openai-codex/gpt-5.5` compares equal to `gpt-5.5`. Keeps config
810/// validation consistent with the runtime check in `reviewer::normalized_model`.
811/// The prefix is only stripped when it leaves a non-empty suffix.
812pub(crate) fn normalized_model(model: &str) -> String {
813    let model = model.trim();
814    let short = model
815        .rsplit_once('/')
816        .and_then(|(_, suffix)| {
817            let s = suffix.trim();
818            if s.is_empty() { None } else { Some(s) }
819        })
820        .unwrap_or(model);
821    short.to_ascii_lowercase()
822}
823
824#[cfg(test)]
825mod tests {
826    use std::path::Path;
827
828    use super::{Effort, TruthMirrorConfig};
829
830    #[test]
831    fn default_config_has_four_opposed_pairs() {
832        let config = TruthMirrorConfig::default();
833
834        assert_eq!(config.pairs.len(), 4);
835        assert_eq!(config.ledger_dir, ".truth");
836        assert!(config.skills.enabled);
837        assert!(config.memory_skill.enabled);
838        assert!(config.memory_skill.effective_enabled(&config.skills));
839        assert_eq!(config.memory_skill.mode, super::MemorySkillMode::Stage);
840        assert_eq!(config.memory_skill.signals.similarity_threshold, 0.55);
841        let codex = config.pair_for("codex").unwrap();
842        assert_eq!(codex.reviewer.harness, "claude");
843        assert_eq!(codex.reviewer.model, "claude-opus-4-8");
844        assert_eq!(codex.reviewer.effort, Effort::Xhigh);
845        let grok = config.pair_for("grok").unwrap();
846        assert_eq!(grok.reviewer.harness, "claude");
847        assert_eq!(grok.reviewer.model, "claude-opus-4-8");
848    }
849
850    #[test]
851    fn reviewer_batching_defaults_to_legacy_serial_petitions() {
852        let config = TruthMirrorConfig::default();
853
854        assert!(!config.reviewer.batch_petitions);
855        assert_eq!(
856            config.reviewer.max_petition_batch_size,
857            super::MAX_PETITION_BATCH_SIZE
858        );
859        assert_eq!(config.reviewer.max_concurrent_runs, 1);
860    }
861
862    #[test]
863    fn reviewer_batching_policy_parses_when_explicitly_enabled() {
864        let contents = r#"
865[reviewer]
866batch_petitions = true
867max_petition_batch_size = 7
868max_concurrent_runs = 2
869"#;
870
871        let config = TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap();
872
873        assert!(config.reviewer.batch_petitions);
874        assert_eq!(config.reviewer.max_petition_batch_size, 7);
875        assert_eq!(config.reviewer.max_concurrent_runs, 2);
876    }
877
878    #[test]
879    fn reviewer_concurrency_rejects_zero_and_values_above_the_bound() {
880        for value in [0, super::MAX_CONCURRENT_REVIEW_RUNS + 1] {
881            let contents = format!("[reviewer]\nmax_concurrent_runs = {value}\n");
882
883            let error =
884                TruthMirrorConfig::from_toml_str(Path::new("config.toml"), &contents).unwrap_err();
885
886            assert!(matches!(
887                error,
888                super::ConfigError::InvalidConcurrentReviewRuns { value: invalid }
889                    if invalid == value
890            ));
891        }
892    }
893
894    #[test]
895    fn reviewer_batch_size_rejects_zero_and_values_above_the_bound() {
896        for value in [0, super::MAX_PETITION_BATCH_SIZE + 1] {
897            let contents =
898                format!("[reviewer]\nbatch_petitions = true\nmax_petition_batch_size = {value}\n");
899
900            let error =
901                TruthMirrorConfig::from_toml_str(Path::new("config.toml"), &contents).unwrap_err();
902
903            assert!(matches!(
904                error,
905                super::ConfigError::InvalidPetitionBatchSize { value: invalid }
906                    if invalid == value
907            ));
908        }
909    }
910
911    #[test]
912    fn reviewer_timeout_defaults_to_twenty_minutes() {
913        let config = TruthMirrorConfig::default();
914
915        assert_eq!(
916            config.reviewer.timeout_secs,
917            super::DEFAULT_REVIEWER_TIMEOUT_SECS
918        );
919        assert_eq!(
920            config.reviewer.timeout(),
921            Some(std::time::Duration::from_secs(20 * 60))
922        );
923    }
924
925    #[test]
926    fn reviewer_timeout_parses_and_zero_disables() {
927        // A wedged reviewer froze the ONYXIO queue for hours (0.9.2 field
928        // report): the timeout is configurable, and 0 opts out explicitly.
929        let contents = "[reviewer]\ntimeout_secs = 90\n";
930        let config = TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap();
931        assert_eq!(
932            config.reviewer.timeout(),
933            Some(std::time::Duration::from_secs(90))
934        );
935
936        let disabled = "[reviewer]\ntimeout_secs = 0\n";
937        let config = TruthMirrorConfig::from_toml_str(Path::new("config.toml"), disabled).unwrap();
938        assert_eq!(config.reviewer.timeout(), None);
939    }
940
941    #[test]
942    fn effort_serializes_lowercase() {
943        assert_eq!(Effort::Xhigh.as_str(), "xhigh");
944        assert_eq!(Effort::highest(), Effort::Xhigh);
945    }
946
947    #[test]
948    fn pairs_config_parses_and_resolves_by_writer() {
949        // Arbiter must be distinct from reviewer after provider-prefix stripping:
950        // gemini/gemini-2.5-pro normalizes to "gemini-2.5-pro", which != "gpt-5.5".
951        let contents = r#"
952default_writer = "claude"
953
954[pairs.claude]
955reviewer = { harness = "codex", model = "gpt-5.5", effort = "xhigh" }
956arbiter  = { harness = "gemini", model = "gemini-2.5-pro", effort = "high" }
957
958[pairs.codex]
959reviewer = { harness = "claude", model = "claude-opus-4-8" }
960"#;
961        let config = TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap();
962
963        let claude_pair = config.pair_for("claude").unwrap();
964        assert_eq!(claude_pair.reviewer.harness, "codex");
965        assert_eq!(claude_pair.reviewer.effort, Effort::Xhigh);
966        assert_eq!(claude_pair.arbiter.as_ref().unwrap().effort, Effort::High);
967
968        // Omitted effort defaults to highest.
969        let codex_pair = config.pair_for("codex").unwrap();
970        assert_eq!(codex_pair.reviewer.effort, Effort::Xhigh);
971    }
972
973    #[test]
974    fn arbiter_same_as_reviewer_after_prefix_strip_is_rejected() {
975        // openai-codex/gpt-5.5 normalizes to gpt-5.5, same as the reviewer model.
976        // This was the bug in the original claude default pair (B1/B2 remediation).
977        let contents = r#"
978[pairs.claude]
979reviewer = { harness = "codex", model = "gpt-5.5" }
980arbiter  = { harness = "pi", model = "openai-codex/gpt-5.5" }
981"#;
982        let result = TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents);
983        assert!(result.is_err());
984        let err = result.unwrap_err().to_string();
985        assert!(
986            err.contains("arbiter"),
987            "expected arbiter error, got: {err}"
988        );
989    }
990
991    #[test]
992    fn default_claude_pair_arbiter_is_distinct_from_reviewer() {
993        // B1 fix: the claude default pair used to have arbiter=pi/openai-codex/gpt-5.5
994        // and reviewer=codex/gpt-5.5. After provider-prefix stripping both normalize
995        // to "gpt-5.5", which is the same model. The fix changed the arbiter to
996        // gemini/gemini-2.5-pro. This test ensures the default config loads without
997        // ArbiterNotDistinct and has a genuinely distinct arbiter.
998        let config = TruthMirrorConfig::default();
999        let pair = config.pair_for("claude").expect("claude pair must exist");
1000        let arbiter = pair.arbiter.as_ref().expect("claude arbiter must be set");
1001        assert_eq!(
1002            arbiter.harness, "gemini",
1003            "claude arbiter harness must be gemini"
1004        );
1005        assert_ne!(
1006            super::normalized_model(&arbiter.model),
1007            super::normalized_model(&pair.reviewer.model),
1008            "claude arbiter must be distinct from reviewer after normalization"
1009        );
1010    }
1011
1012    #[test]
1013    fn normalized_strips_provider_prefix_for_comparison() {
1014        // B2: openai-codex/gpt-5.5 and gpt-5.5 should normalize to the same token.
1015        assert_eq!(
1016            super::normalized_model("openai-codex/gpt-5.5"),
1017            super::normalized_model("gpt-5.5")
1018        );
1019        // Prefix-only values (bare "/") keep their identity to avoid false collisions.
1020        assert_ne!(super::normalized_model("/"), super::normalized_model(""));
1021    }
1022
1023    #[test]
1024    fn legacy_review_block_overrides_default_pair() {
1025        // Reviewer distinct from the built-in codex default (claude/opus-4-8) so
1026        // the test proves the legacy block actually overrides the default rather
1027        // than coincidentally matching it.
1028        let contents = r#"
1029ledger_dir = ".truth-mirror"
1030
1031[review.watched]
1032harness = "codex"
1033model = "gpt-5.5"
1034
1035[review.reviewer]
1036harness = "gemini"
1037model = "gemini-3-pro"
1038"#;
1039        let config = TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap();
1040
1041        let pair = config.pair_for("codex").unwrap();
1042        assert_eq!(pair.reviewer.harness, "gemini");
1043        assert_eq!(pair.reviewer.model, "gemini-3-pro");
1044        // Other writers still resolve from defaults.
1045        assert!(config.pair_for("claude").is_some());
1046        assert!(config.pair_for("pi").is_some());
1047        assert!(config.pair_for("grok").is_some());
1048    }
1049
1050    #[test]
1051    fn explicit_pairs_win_over_legacy_review() {
1052        // A mixed migration config must not let legacy [review] clobber a modern
1053        // explicit [pairs.<writer>] entry.
1054        let contents = r#"
1055[pairs.codex]
1056reviewer = { harness = "claude", model = "explicit-model" }
1057
1058[review.watched]
1059harness = "codex"
1060model = "gpt-5.5"
1061
1062[review.reviewer]
1063harness = "gemini"
1064model = "legacy-model"
1065"#;
1066        let config = TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap();
1067
1068        let pair = config.pair_for("codex").unwrap();
1069        assert_eq!(pair.reviewer.harness, "claude");
1070        assert_eq!(pair.reviewer.model, "explicit-model");
1071    }
1072
1073    #[test]
1074    fn explicit_pairs_win_case_insensitively_over_legacy() {
1075        // `[pairs.CODEX]` (uppercase) must not be clobbered by a legacy [review]
1076        // folded under lowercase `codex`.
1077        let contents = r#"
1078[pairs.CODEX]
1079reviewer = { harness = "claude", model = "explicit-model" }
1080
1081[review.watched]
1082harness = "codex"
1083model = "gpt-5.5"
1084
1085[review.reviewer]
1086harness = "gemini"
1087model = "legacy-model"
1088"#;
1089        let config = TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap();
1090
1091        let pair = config.pair_for("codex").unwrap();
1092        assert_eq!(pair.reviewer.model, "explicit-model");
1093        // No duplicate CODEX/codex entries.
1094        assert_eq!(config.pairs.len(), 1);
1095    }
1096
1097    #[test]
1098    fn arbiter_equal_to_reviewer_model_is_rejected() {
1099        let contents = r#"
1100[pairs.codex]
1101reviewer = { harness = "claude", model = "same-model" }
1102arbiter  = { harness = "pi", model = "same-model" }
1103"#;
1104        let error =
1105            TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap_err();
1106
1107        assert!(matches!(
1108            error,
1109            super::ConfigError::ArbiterNotDistinct { .. }
1110        ));
1111    }
1112
1113    #[test]
1114    fn missing_config_loads_default() {
1115        let config = TruthMirrorConfig::load_or_default("missing-config.toml").unwrap();
1116
1117        assert_eq!(config.pairs.len(), 4);
1118        assert!(config.ground_truth.enabled);
1119        assert_eq!(config.history.window_user, 3);
1120        assert!(!config.enforcement.is_enabled());
1121    }
1122
1123    #[test]
1124    fn gates_config_parses_and_builds_policy() {
1125        let contents = r#"
1126[pairs.codex]
1127reviewer = { harness = "claude", model = "claude-opus-4-8" }
1128
1129[gates]
1130fake_markers = ["pretend-pass"]
1131evidence_patterns = ["jira:"]
1132marker_ignore_paths = [".md", "vendor/"]
1133"#;
1134        let config = TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap();
1135
1136        // Config values are ADDITIVE: built-in defaults always apply, plus the
1137        // repo's custom entries. A repo can add but not silently disable defaults.
1138        // The default marker literal is built at runtime so it never appears as an
1139        // added line in this file (truth-mirror's own gate would flag it).
1140        let default_marker = ["mock", "as", "real"].join("-");
1141        let policy = config.gates.to_policy();
1142        assert!(policy.fake_markers.iter().any(|m| m == "pretend-pass"));
1143        assert!(policy.fake_markers.contains(&default_marker));
1144        assert!(policy.evidence_patterns.iter().any(|p| p == "jira:"));
1145        assert!(policy.evidence_patterns.iter().any(|p| p == "tests:"));
1146        assert!(policy.marker_ignore_paths.iter().any(|p| p == "vendor/"));
1147        assert!(policy.marker_ignore_paths.iter().any(|p| p == "openspec/"));
1148    }
1149
1150    #[test]
1151    fn memory_skill_effective_enabled_requires_parent_self_and_active_mode() {
1152        let skills_enabled = super::SkillsConfig { enabled: true };
1153        let skills_disabled = super::SkillsConfig { enabled: false };
1154        let mut memory = super::MemorySkillConfig {
1155            enabled: true,
1156            mode: super::MemorySkillMode::Stage,
1157            ..super::MemorySkillConfig::default()
1158        };
1159
1160        assert!(memory.effective_enabled(&skills_enabled));
1161        assert!(!memory.effective_enabled(&skills_disabled));
1162
1163        memory.enabled = false;
1164        assert!(!memory.effective_enabled(&skills_enabled));
1165
1166        memory.enabled = true;
1167        memory.mode = super::MemorySkillMode::Off;
1168        assert!(!memory.effective_enabled(&skills_enabled));
1169    }
1170
1171    #[test]
1172    fn missing_config_file_enables_memory_skill() {
1173        let temp = tempfile::tempdir().unwrap();
1174        let config = TruthMirrorConfig::load_or_default(temp.path().join("missing.toml")).unwrap();
1175
1176        assert!(config.skills.enabled);
1177        assert!(config.memory_skill.enabled);
1178        assert!(config.memory_skill.effective_enabled(&config.skills));
1179    }
1180
1181    #[test]
1182    fn empty_config_enables_memory_skill() {
1183        let config = TruthMirrorConfig::from_toml_str(Path::new("config.toml"), "").unwrap();
1184
1185        assert!(config.skills.enabled);
1186        assert!(config.memory_skill.enabled);
1187        assert!(config.memory_skill.effective_enabled(&config.skills));
1188    }
1189
1190    #[test]
1191    fn config_without_skill_sections_enables_memory_skill() {
1192        let contents = r#"
1193[history]
1194window_user = 5
1195"#;
1196        let config = TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap();
1197
1198        assert!(config.skills.enabled);
1199        assert!(config.memory_skill.enabled);
1200        assert!(config.memory_skill.effective_enabled(&config.skills));
1201    }
1202
1203    #[test]
1204    fn memory_skill_rejection_precedence_is_legacy_compatible() {
1205        let contents = r#"
1206[memory_skill.signals]
1207rejection_precedence = ["how_to_skill"]
1208"#;
1209
1210        let config = TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap();
1211
1212        assert_eq!(
1213            config.memory_skill.signals.rejection_precedence,
1214            vec![super::MemorySkillCandidateKind::HowToSkill]
1215        );
1216    }
1217
1218    #[test]
1219    fn memory_skill_entropy_threshold_must_be_finite() {
1220        let contents = r#"
1221[memory_skill.scan]
1222entropy_threshold = inf
1223"#;
1224
1225        let error =
1226            TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap_err();
1227
1228        assert!(matches!(
1229            error,
1230            super::ConfigError::InvalidMemorySkillEntropyThreshold { .. }
1231        ));
1232    }
1233
1234    #[test]
1235    fn memory_skill_entropy_threshold_must_be_positive() {
1236        let contents = r#"
1237[memory_skill.scan]
1238entropy_threshold = 0.0
1239"#;
1240
1241        let error =
1242            TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap_err();
1243
1244        assert!(matches!(
1245            error,
1246            super::ConfigError::InvalidMemorySkillEntropyThreshold { .. }
1247        ));
1248    }
1249
1250    #[test]
1251    fn memory_skill_max_skill_bytes_must_be_positive() {
1252        let contents = r#"
1253[memory_skill]
1254max_skill_bytes = 0
1255"#;
1256
1257        let error =
1258            TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap_err();
1259
1260        assert!(matches!(
1261            error,
1262            super::ConfigError::InvalidMemorySkillMaxSkillBytes
1263        ));
1264    }
1265
1266    #[test]
1267    fn memory_skill_secret_detector_timeout_must_be_positive() {
1268        let contents = r#"
1269[memory_skill.scan]
1270secret_detector_timeout_seconds = 0
1271"#;
1272
1273        let error =
1274            TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap_err();
1275
1276        assert!(matches!(
1277            error,
1278            super::ConfigError::InvalidMemorySkillSecretDetectorTimeout
1279        ));
1280    }
1281
1282    #[test]
1283    fn memory_skill_similarity_threshold_must_be_finite_and_bounded() {
1284        for value in ["-0.1", "1.1", "nan", "inf"] {
1285            let contents = format!("[memory_skill.signals]\nsimilarity_threshold = {value}\n");
1286            let error =
1287                TruthMirrorConfig::from_toml_str(Path::new("config.toml"), &contents).unwrap_err();
1288            assert!(matches!(
1289                error,
1290                super::ConfigError::InvalidMemorySkillSimilarityThreshold { .. }
1291            ));
1292        }
1293    }
1294
1295    #[test]
1296    fn empty_gate_lists_fall_back_to_defaults() {
1297        // An explicitly empty list must not silently disable a gate.
1298        let policy = super::GatesConfig {
1299            fake_markers: Vec::new(),
1300            evidence_patterns: Vec::new(),
1301            marker_ignore_paths: Vec::new(),
1302        }
1303        .to_policy();
1304
1305        assert!(!policy.fake_markers.is_empty());
1306        assert!(!policy.evidence_patterns.is_empty());
1307        assert!(!policy.marker_ignore_paths.is_empty());
1308    }
1309
1310    #[test]
1311    fn memory_skill_blocked_patterns_trim_and_dedupe() {
1312        let config = super::MemorySkillScanConfig {
1313            blocked_patterns: vec![
1314                " API_KEY ".to_owned(),
1315                " custom ".to_owned(),
1316                " ".to_owned(),
1317                "custom".to_owned(),
1318            ],
1319            ..super::MemorySkillScanConfig::default()
1320        };
1321
1322        let patterns = config.effective_blocked_patterns();
1323
1324        assert_eq!(
1325            patterns
1326                .iter()
1327                .filter(|pattern| pattern.eq_ignore_ascii_case("api_key"))
1328                .count(),
1329            1
1330        );
1331        assert_eq!(
1332            patterns
1333                .iter()
1334                .filter(|pattern| *pattern == "custom")
1335                .count(),
1336            1
1337        );
1338        assert!(patterns.iter().all(|pattern| pattern.trim() == pattern));
1339    }
1340
1341    #[test]
1342    fn pair_keys_are_lowercased() {
1343        let contents = r#"
1344[pairs.CODEX]
1345reviewer = { harness = "claude", model = "claude-opus-4-8" }
1346"#;
1347        let config = TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap();
1348
1349        assert!(config.pair_for("codex").is_some());
1350        assert!(config.pair_for("CoDeX").is_some());
1351    }
1352}