Skip to main content

zeph_config/
classifiers.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use serde::{Deserialize, Serialize};
5
6fn default_classifier_timeout_ms() -> u64 {
7    5000
8}
9
10fn default_injection_model() -> String {
11    "protectai/deberta-v3-small-prompt-injection-v2".into()
12}
13
14fn default_injection_threshold() -> f32 {
15    0.95
16}
17
18fn default_injection_threshold_soft() -> f32 {
19    0.5
20}
21
22fn default_enforcement_mode() -> InjectionEnforcementMode {
23    InjectionEnforcementMode::Warn
24}
25
26fn default_pii_model() -> String {
27    "iiiorg/piiranha-v1-detect-personal-information".into()
28}
29
30fn default_pii_threshold() -> f32 {
31    0.75
32}
33
34fn default_pii_ner_max_chars() -> usize {
35    8192
36}
37
38fn default_pii_ner_circuit_breaker() -> u32 {
39    2
40}
41
42fn default_pii_ner_allowlist() -> Vec<String> {
43    vec![
44        "Zeph".into(),
45        "Rust".into(),
46        "OpenAI".into(),
47        "Ollama".into(),
48        "Claude".into(),
49    ]
50}
51
52fn default_three_class_threshold() -> f32 {
53    0.7
54}
55
56/// Enforcement mode for the injection classifier.
57///
58/// `warn` (default): scores above `injection_threshold` emit WARN and increment metrics
59/// but do NOT block content. Use this when deploying `DeBERTa` classifiers on tool outputs —
60/// FPR of 12-37% on benign content makes hard-blocking unsafe.
61///
62/// `block`: scores above `injection_threshold` block content (behavior before v0.17).
63/// Only safe for well-calibrated models or when FPR is verified on your workload.
64#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
65#[serde(rename_all = "snake_case")]
66#[non_exhaustive]
67pub enum InjectionEnforcementMode {
68    /// Log + metric only, never block.
69    Warn,
70    /// Block content above hard threshold.
71    Block,
72}
73
74/// Configuration for the ML-backed classifier subsystem.
75///
76/// Placed under `[classifiers]` in `config.toml`. All fields are optional with safe defaults
77/// so existing configs continue to work when this section is absent.
78///
79/// When `enabled = false` (the default), all classifier code is bypassed and the existing
80/// regex-based detection runs unchanged.
81#[derive(Clone, PartialEq, Deserialize, Serialize)]
82pub struct ClassifiersConfig {
83    /// Master switch. When `false`, classifiers are never loaded or invoked.
84    #[serde(default)]
85    pub enabled: bool,
86
87    /// Per-inference timeout in milliseconds.
88    ///
89    /// On timeout the call site falls back to regex. Separate from model download time.
90    #[serde(default = "default_classifier_timeout_ms")]
91    pub timeout_ms: u64,
92
93    /// Resolved `HuggingFace` Hub API token.
94    ///
95    /// Must be the **token value** (not a vault key name) — resolved by the caller before
96    /// constructing `ClassifiersConfig`. When `None`, model downloads are unauthenticated,
97    /// which fails for gated or private repos.
98    #[serde(default)]
99    pub hf_token: Option<String>,
100
101    /// When `true`, the ML injection classifier runs on direct user chat messages.
102    ///
103    /// Default `false`: the `DeBERTa` model is intended for external/untrusted content
104    /// (tool output, web scrapes) — not for direct user input. Enabling this may cause
105    /// false positives on benign conversational messages.
106    #[serde(default)]
107    pub scan_user_input: bool,
108
109    /// `HuggingFace` repo ID for the injection detection model.
110    #[serde(default = "default_injection_model")]
111    pub injection_model: String,
112
113    /// Enforcement mode for the injection classifier.
114    ///
115    /// `warn` (default): scores above `injection_threshold` emit WARN and increment metrics
116    /// but do NOT block content. Use this when deploying classifiers on tool outputs —
117    /// FPR of 12-37% on benign content makes hard-blocking unsafe.
118    ///
119    /// `block`: scores above `injection_threshold` block content. Only safe for well-calibrated
120    /// models or when FPR is verified on your workload.
121    #[serde(default = "default_enforcement_mode")]
122    pub enforcement_mode: InjectionEnforcementMode,
123
124    /// Soft threshold: classifier score at or above this emits a WARN log and increments
125    /// the suspicious-injection metric, but content is allowed through.
126    ///
127    /// Range: `(0.0, 1.0]`. Default `0.5`. Must be ≤ `injection_threshold`.
128    #[serde(
129        default = "default_injection_threshold_soft",
130        deserialize_with = "crate::de_helpers::de_unit_open"
131    )]
132    pub injection_threshold_soft: f32,
133
134    /// Hard threshold: classifier score at or above this blocks the content (in `block` mode)
135    /// or emits WARN (in `warn` mode).
136    ///
137    /// Range: `(0.0, 1.0]`. Conservative default of `0.95` minimises false positives.
138    /// Real-world ML injection classifiers have 12–37% recall gaps at high thresholds —
139    /// defense-in-depth via regex fallback and spotlighting is mandatory.
140    #[serde(
141        default = "default_injection_threshold",
142        deserialize_with = "crate::de_helpers::de_unit_open"
143    )]
144    pub injection_threshold: f32,
145
146    /// Optional SHA-256 hex digest of the injection model safetensors file.
147    ///
148    /// When set, the file is verified before loading. Mismatch aborts startup with an error.
149    /// Useful for security-sensitive deployments to detect corruption or tampering.
150    #[serde(default)]
151    pub injection_model_sha256: Option<String>,
152
153    /// Optional `HuggingFace` repo ID or local path for the three-class `AlignSentinel` model.
154    ///
155    /// When set, content flagged as Suspicious or Blocked by the binary `DeBERTa` classifier
156    /// is passed to this model for refinement. If the three-class model classifies the content
157    /// as `aligned-instruction` or `no-instruction`, the verdict is downgraded to `Clean`.
158    /// This directly reduces false positives from legitimate instruction-style content.
159    #[serde(default)]
160    pub three_class_model: Option<String>,
161
162    /// Confidence threshold for the three-class model's `misaligned-instruction` label.
163    ///
164    /// Content is only kept as Suspicious/Blocked when the misaligned score meets this threshold.
165    /// Range: `(0.0, 1.0]`. Default `0.7`.
166    #[serde(
167        default = "default_three_class_threshold",
168        deserialize_with = "crate::de_helpers::de_unit_open"
169    )]
170    pub three_class_threshold: f32,
171
172    /// Optional SHA-256 hex digest of the three-class model safetensors file.
173    #[serde(default)]
174    pub three_class_model_sha256: Option<String>,
175
176    /// Enable PII detection via the NER model (`pii_model`).
177    ///
178    /// When `true`, `CandlePiiClassifier` runs on user messages in addition to the
179    /// regex-based `PiiFilter`. Both results are merged (union with deduplication).
180    #[serde(default)]
181    pub pii_enabled: bool,
182
183    /// `HuggingFace` repo ID for the PII NER model.
184    #[serde(default = "default_pii_model")]
185    pub pii_model: String,
186
187    /// Minimum per-token confidence to accept a PII label.
188    ///
189    /// Tokens below this threshold are treated as O (no entity).
190    /// Default `0.75` balances recall on rarer entity types (DRIVERLICENSE, PASSPORT, IBAN)
191    /// with precision. Raise to `0.85` to prefer precision over recall.
192    #[serde(default = "default_pii_threshold")]
193    pub pii_threshold: f32,
194
195    /// Optional SHA-256 hex digest of the PII model safetensors file.
196    #[serde(default)]
197    pub pii_model_sha256: Option<String>,
198
199    /// Maximum number of bytes passed to the NER PII classifier per call.
200    ///
201    /// Input is truncated at a valid UTF-8 boundary before classification to prevent
202    /// timeout on large tool outputs (e.g. `search_code`). Default `8192`.
203    #[serde(default = "default_pii_ner_max_chars")]
204    pub pii_ner_max_chars: usize,
205
206    /// Allowlist of tokens that are never redacted by the NER PII classifier, regardless
207    /// of model confidence.
208    ///
209    /// Matching is case-insensitive and exact (whole span text must equal an allowlist entry).
210    /// This suppresses common false positives from the piiranha model — for example,
211    /// "Zeph" is misclassified as a city (PII:CITY) by the base model.
212    ///
213    /// Default entries: `["Zeph", "Rust", "OpenAI", "Ollama", "Claude"]`.
214    /// Set to `[]` to disable the allowlist entirely.
215    #[serde(default = "default_pii_ner_allowlist")]
216    pub pii_ner_allowlist: Vec<String>,
217
218    /// Number of consecutive NER timeouts before the circuit breaker trips and disables NER
219    /// for the remainder of the session.
220    ///
221    /// When the breaker trips, all subsequent chunks fall back to regex-only PII detection,
222    /// preventing repeated timeout stalls on paginated reads (e.g. 12 chunks × 30 s = 6 min).
223    /// Set to `0` to disable the circuit breaker (NER is always attempted).
224    ///
225    /// Default: `2`. Takes effect on the next session start if changed mid-session.
226    #[serde(default = "default_pii_ner_circuit_breaker")]
227    pub pii_ner_circuit_breaker: u32,
228}
229
230impl Default for ClassifiersConfig {
231    fn default() -> Self {
232        Self {
233            enabled: false,
234            timeout_ms: default_classifier_timeout_ms(),
235            hf_token: None,
236            scan_user_input: false,
237            injection_model: default_injection_model(),
238            enforcement_mode: default_enforcement_mode(),
239            injection_threshold_soft: default_injection_threshold_soft(),
240            injection_threshold: default_injection_threshold(),
241            injection_model_sha256: None,
242            three_class_model: None,
243            three_class_threshold: default_three_class_threshold(),
244            three_class_model_sha256: None,
245            pii_enabled: false,
246            pii_model: default_pii_model(),
247            pii_threshold: default_pii_threshold(),
248            pii_model_sha256: None,
249            pii_ner_max_chars: default_pii_ner_max_chars(),
250            pii_ner_allowlist: default_pii_ner_allowlist(),
251            pii_ner_circuit_breaker: default_pii_ner_circuit_breaker(),
252        }
253    }
254}
255
256impl std::fmt::Debug for ClassifiersConfig {
257    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
258        f.debug_struct("ClassifiersConfig")
259            .field("enabled", &self.enabled)
260            .field("timeout_ms", &self.timeout_ms)
261            .field("hf_token", &self.hf_token.as_ref().map(|_| "[REDACTED]"))
262            .field("scan_user_input", &self.scan_user_input)
263            .field("injection_model", &self.injection_model)
264            .field("enforcement_mode", &self.enforcement_mode)
265            .field("injection_threshold_soft", &self.injection_threshold_soft)
266            .field("injection_threshold", &self.injection_threshold)
267            .field("injection_model_sha256", &self.injection_model_sha256)
268            .field("three_class_model", &self.three_class_model)
269            .field("three_class_threshold", &self.three_class_threshold)
270            .field("three_class_model_sha256", &self.three_class_model_sha256)
271            .field("pii_enabled", &self.pii_enabled)
272            .field("pii_model", &self.pii_model)
273            .field("pii_threshold", &self.pii_threshold)
274            .field("pii_model_sha256", &self.pii_model_sha256)
275            .field("pii_ner_max_chars", &self.pii_ner_max_chars)
276            .field("pii_ner_allowlist", &self.pii_ner_allowlist)
277            .field("pii_ner_circuit_breaker", &self.pii_ner_circuit_breaker)
278            .finish()
279    }
280}
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285
286    #[test]
287    fn default_values() {
288        let cfg = ClassifiersConfig::default();
289        assert!(!cfg.enabled);
290        assert_eq!(cfg.timeout_ms, 5000);
291        assert!(cfg.hf_token.is_none());
292        assert!(!cfg.scan_user_input);
293        assert_eq!(
294            cfg.injection_model,
295            "protectai/deberta-v3-small-prompt-injection-v2"
296        );
297        assert_eq!(cfg.enforcement_mode, InjectionEnforcementMode::Warn);
298        assert!((cfg.injection_threshold_soft - 0.5).abs() < 1e-6);
299        assert!((cfg.injection_threshold - 0.95).abs() < 1e-6);
300        assert!(cfg.injection_model_sha256.is_none());
301        assert!(cfg.three_class_model.is_none());
302        assert!((cfg.three_class_threshold - 0.7).abs() < 1e-6);
303        assert!(cfg.three_class_model_sha256.is_none());
304        assert!(!cfg.pii_enabled);
305        assert_eq!(
306            cfg.pii_model,
307            "iiiorg/piiranha-v1-detect-personal-information"
308        );
309        assert!((cfg.pii_threshold - 0.75).abs() < 1e-6);
310        assert!(cfg.pii_model_sha256.is_none());
311        assert_eq!(
312            cfg.pii_ner_allowlist,
313            vec!["Zeph", "Rust", "OpenAI", "Ollama", "Claude"]
314        );
315    }
316
317    #[test]
318    fn hf_token_and_scan_user_input_round_trip() {
319        let toml = r#"
320            hf_token = "hf_secret"
321            scan_user_input = true
322        "#;
323        let cfg: ClassifiersConfig = toml::from_str(toml).unwrap();
324        assert_eq!(cfg.hf_token.as_deref(), Some("hf_secret"));
325        assert!(cfg.scan_user_input);
326    }
327
328    #[test]
329    fn deserialize_empty_section_uses_defaults() {
330        let cfg: ClassifiersConfig = toml::from_str("").unwrap();
331        assert!(!cfg.enabled);
332        assert_eq!(cfg.timeout_ms, 5000);
333        assert_eq!(
334            cfg.injection_model,
335            "protectai/deberta-v3-small-prompt-injection-v2"
336        );
337        assert!((cfg.injection_threshold_soft - 0.5).abs() < 1e-6);
338        assert!((cfg.injection_threshold - 0.95).abs() < 1e-6);
339        assert!(!cfg.pii_enabled);
340        assert!((cfg.pii_threshold - 0.75).abs() < 1e-6);
341    }
342
343    #[test]
344    fn deserialize_custom_values() {
345        let toml = r#"
346            enabled = true
347            timeout_ms = 2000
348            injection_model = "custom/model-v1"
349            injection_threshold = 0.9
350            pii_enabled = true
351            pii_threshold = 0.85
352        "#;
353        let cfg: ClassifiersConfig = toml::from_str(toml).unwrap();
354        assert!(cfg.enabled);
355        assert_eq!(cfg.timeout_ms, 2000);
356        assert_eq!(cfg.injection_model, "custom/model-v1");
357        assert!((cfg.injection_threshold_soft - 0.5).abs() < 1e-6);
358        assert!((cfg.injection_threshold - 0.9).abs() < 1e-6);
359        assert!(cfg.pii_enabled);
360        assert!((cfg.pii_threshold - 0.85).abs() < 1e-6);
361    }
362
363    #[test]
364    fn deserialize_sha256_fields() {
365        let toml = r#"
366            injection_model_sha256 = "abc123"
367            pii_model_sha256 = "def456"
368        "#;
369        let cfg: ClassifiersConfig = toml::from_str(toml).unwrap();
370        assert_eq!(cfg.injection_model_sha256.as_deref(), Some("abc123"));
371        assert_eq!(cfg.pii_model_sha256.as_deref(), Some("def456"));
372    }
373
374    #[test]
375    fn serialize_roundtrip() {
376        let original = ClassifiersConfig {
377            enabled: true,
378            timeout_ms: 3000,
379            hf_token: Some("hf_test_token".into()),
380            scan_user_input: true,
381            injection_model: "org/model".into(),
382            enforcement_mode: InjectionEnforcementMode::Block,
383            injection_threshold_soft: 0.45,
384            injection_threshold: 0.75,
385            injection_model_sha256: Some("deadbeef".into()),
386            three_class_model: Some("org/three-class".into()),
387            three_class_threshold: 0.65,
388            three_class_model_sha256: Some("abc456".into()),
389            pii_enabled: true,
390            pii_model: "org/pii-model".into(),
391            pii_threshold: 0.80,
392            pii_model_sha256: None,
393            pii_ner_max_chars: 4096,
394            pii_ner_allowlist: vec!["MyProject".into(), "Rust".into()],
395            pii_ner_circuit_breaker: 3,
396        };
397        let serialized = toml::to_string(&original).unwrap();
398        let deserialized: ClassifiersConfig = toml::from_str(&serialized).unwrap();
399        assert_eq!(original, deserialized);
400    }
401
402    #[test]
403    fn dual_threshold_deserialization() {
404        let toml = r"
405            injection_threshold_soft = 0.4
406            injection_threshold = 0.85
407        ";
408        let cfg: ClassifiersConfig = toml::from_str(toml).unwrap();
409        assert!((cfg.injection_threshold_soft - 0.4).abs() < 1e-6);
410        assert!((cfg.injection_threshold - 0.85).abs() < 1e-6);
411    }
412
413    #[test]
414    fn soft_threshold_defaults_when_only_hard_provided() {
415        let toml = "injection_threshold = 0.9";
416        let cfg: ClassifiersConfig = toml::from_str(toml).unwrap();
417        assert!((cfg.injection_threshold_soft - 0.5).abs() < 1e-6);
418        assert!((cfg.injection_threshold - 0.9).abs() < 1e-6);
419    }
420
421    #[test]
422    fn partial_override_timeout_only() {
423        let toml = "timeout_ms = 1000";
424        let cfg: ClassifiersConfig = toml::from_str(toml).unwrap();
425        assert!(!cfg.enabled);
426        assert_eq!(cfg.timeout_ms, 1000);
427        assert_eq!(
428            cfg.injection_model,
429            "protectai/deberta-v3-small-prompt-injection-v2"
430        );
431        assert!((cfg.injection_threshold_soft - 0.5).abs() < 1e-6);
432        assert!((cfg.injection_threshold - 0.95).abs() < 1e-6);
433    }
434
435    #[test]
436    fn enforcement_mode_warn_is_default() {
437        let cfg: ClassifiersConfig = toml::from_str("").unwrap();
438        assert_eq!(cfg.enforcement_mode, InjectionEnforcementMode::Warn);
439    }
440
441    #[test]
442    fn enforcement_mode_block_roundtrip() {
443        let toml = r#"enforcement_mode = "block""#;
444        let cfg: ClassifiersConfig = toml::from_str(toml).unwrap();
445        assert_eq!(cfg.enforcement_mode, InjectionEnforcementMode::Block);
446        let back = toml::to_string(&cfg).unwrap();
447        let cfg2: ClassifiersConfig = toml::from_str(&back).unwrap();
448        assert_eq!(cfg2.enforcement_mode, InjectionEnforcementMode::Block);
449    }
450
451    #[test]
452    fn threshold_validation_rejects_zero() {
453        let result: Result<ClassifiersConfig, _> = toml::from_str("injection_threshold = 0.0");
454        assert!(result.is_err());
455    }
456
457    #[test]
458    fn threshold_validation_rejects_above_one() {
459        let result: Result<ClassifiersConfig, _> = toml::from_str("injection_threshold = 1.1");
460        assert!(result.is_err());
461    }
462
463    #[test]
464    fn threshold_validation_accepts_exactly_one() {
465        let cfg: ClassifiersConfig = toml::from_str("injection_threshold = 1.0").unwrap();
466        assert!((cfg.injection_threshold - 1.0).abs() < 1e-6);
467    }
468
469    #[test]
470    fn threshold_validation_soft_rejects_zero() {
471        let result: Result<ClassifiersConfig, _> = toml::from_str("injection_threshold_soft = 0.0");
472        assert!(result.is_err());
473    }
474
475    #[test]
476    fn three_class_model_roundtrip() {
477        let toml = r#"
478            three_class_model = "org/align-sentinel"
479            three_class_threshold = 0.65
480            three_class_model_sha256 = "aabbcc"
481        "#;
482        let cfg: ClassifiersConfig = toml::from_str(toml).unwrap();
483        assert_eq!(cfg.three_class_model.as_deref(), Some("org/align-sentinel"));
484        assert!((cfg.three_class_threshold - 0.65).abs() < 1e-6);
485        assert_eq!(cfg.three_class_model_sha256.as_deref(), Some("aabbcc"));
486    }
487
488    #[test]
489    fn pii_ner_allowlist_default_entries() {
490        let cfg = ClassifiersConfig::default();
491        assert!(cfg.pii_ner_allowlist.contains(&"Zeph".to_owned()));
492        assert!(cfg.pii_ner_allowlist.contains(&"Rust".to_owned()));
493        assert!(cfg.pii_ner_allowlist.contains(&"OpenAI".to_owned()));
494        assert!(cfg.pii_ner_allowlist.contains(&"Ollama".to_owned()));
495        assert!(cfg.pii_ner_allowlist.contains(&"Claude".to_owned()));
496    }
497
498    #[test]
499    fn pii_ner_allowlist_configurable() {
500        let toml = r#"pii_ner_allowlist = ["MyProject", "AcmeCorp"]"#;
501        let cfg: ClassifiersConfig = toml::from_str(toml).unwrap();
502        assert_eq!(cfg.pii_ner_allowlist, vec!["MyProject", "AcmeCorp"]);
503    }
504
505    #[test]
506    fn pii_ner_allowlist_empty_disables() {
507        let toml = "pii_ner_allowlist = []";
508        let cfg: ClassifiersConfig = toml::from_str(toml).unwrap();
509        assert!(cfg.pii_ner_allowlist.is_empty());
510    }
511
512    #[test]
513    fn three_class_threshold_validation_rejects_zero() {
514        let result: Result<ClassifiersConfig, _> = toml::from_str("three_class_threshold = 0.0");
515        assert!(result.is_err());
516    }
517
518    #[test]
519    fn pii_ner_circuit_breaker_default() {
520        let cfg = ClassifiersConfig::default();
521        assert_eq!(cfg.pii_ner_circuit_breaker, 2);
522    }
523
524    #[test]
525    fn pii_ner_circuit_breaker_configurable() {
526        let cfg: ClassifiersConfig = toml::from_str("pii_ner_circuit_breaker = 5").unwrap();
527        assert_eq!(cfg.pii_ner_circuit_breaker, 5);
528    }
529
530    #[test]
531    fn pii_ner_circuit_breaker_zero_disables() {
532        let cfg: ClassifiersConfig = toml::from_str("pii_ner_circuit_breaker = 0").unwrap();
533        assert_eq!(cfg.pii_ner_circuit_breaker, 0);
534    }
535
536    #[test]
537    fn pii_ner_circuit_breaker_missing_uses_default() {
538        let cfg: ClassifiersConfig = toml::from_str("").unwrap();
539        assert_eq!(cfg.pii_ner_circuit_breaker, 2);
540    }
541
542    #[test]
543    fn classifiers_config_debug_redacts_hf_token() {
544        let cfg = ClassifiersConfig {
545            hf_token: Some("hf_SUPERSECRET".to_owned()),
546            ..ClassifiersConfig::default()
547        };
548        let dbg = format!("{cfg:?}");
549        assert!(!dbg.contains("hf_SUPERSECRET"));
550        assert!(dbg.contains("[REDACTED]"));
551    }
552
553    #[test]
554    fn classifiers_config_debug_none_hf_token() {
555        let cfg = ClassifiersConfig::default();
556        let dbg = format!("{cfg:?}");
557        assert!(!dbg.contains("[REDACTED]"));
558        assert!(dbg.contains("hf_token: None"));
559    }
560}