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(Debug, 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
256#[cfg(test)]
257mod tests {
258    use super::*;
259
260    #[test]
261    fn default_values() {
262        let cfg = ClassifiersConfig::default();
263        assert!(!cfg.enabled);
264        assert_eq!(cfg.timeout_ms, 5000);
265        assert!(cfg.hf_token.is_none());
266        assert!(!cfg.scan_user_input);
267        assert_eq!(
268            cfg.injection_model,
269            "protectai/deberta-v3-small-prompt-injection-v2"
270        );
271        assert_eq!(cfg.enforcement_mode, InjectionEnforcementMode::Warn);
272        assert!((cfg.injection_threshold_soft - 0.5).abs() < 1e-6);
273        assert!((cfg.injection_threshold - 0.95).abs() < 1e-6);
274        assert!(cfg.injection_model_sha256.is_none());
275        assert!(cfg.three_class_model.is_none());
276        assert!((cfg.three_class_threshold - 0.7).abs() < 1e-6);
277        assert!(cfg.three_class_model_sha256.is_none());
278        assert!(!cfg.pii_enabled);
279        assert_eq!(
280            cfg.pii_model,
281            "iiiorg/piiranha-v1-detect-personal-information"
282        );
283        assert!((cfg.pii_threshold - 0.75).abs() < 1e-6);
284        assert!(cfg.pii_model_sha256.is_none());
285        assert_eq!(
286            cfg.pii_ner_allowlist,
287            vec!["Zeph", "Rust", "OpenAI", "Ollama", "Claude"]
288        );
289    }
290
291    #[test]
292    fn hf_token_and_scan_user_input_round_trip() {
293        let toml = r#"
294            hf_token = "hf_secret"
295            scan_user_input = true
296        "#;
297        let cfg: ClassifiersConfig = toml::from_str(toml).unwrap();
298        assert_eq!(cfg.hf_token.as_deref(), Some("hf_secret"));
299        assert!(cfg.scan_user_input);
300    }
301
302    #[test]
303    fn deserialize_empty_section_uses_defaults() {
304        let cfg: ClassifiersConfig = toml::from_str("").unwrap();
305        assert!(!cfg.enabled);
306        assert_eq!(cfg.timeout_ms, 5000);
307        assert_eq!(
308            cfg.injection_model,
309            "protectai/deberta-v3-small-prompt-injection-v2"
310        );
311        assert!((cfg.injection_threshold_soft - 0.5).abs() < 1e-6);
312        assert!((cfg.injection_threshold - 0.95).abs() < 1e-6);
313        assert!(!cfg.pii_enabled);
314        assert!((cfg.pii_threshold - 0.75).abs() < 1e-6);
315    }
316
317    #[test]
318    fn deserialize_custom_values() {
319        let toml = r#"
320            enabled = true
321            timeout_ms = 2000
322            injection_model = "custom/model-v1"
323            injection_threshold = 0.9
324            pii_enabled = true
325            pii_threshold = 0.85
326        "#;
327        let cfg: ClassifiersConfig = toml::from_str(toml).unwrap();
328        assert!(cfg.enabled);
329        assert_eq!(cfg.timeout_ms, 2000);
330        assert_eq!(cfg.injection_model, "custom/model-v1");
331        assert!((cfg.injection_threshold_soft - 0.5).abs() < 1e-6);
332        assert!((cfg.injection_threshold - 0.9).abs() < 1e-6);
333        assert!(cfg.pii_enabled);
334        assert!((cfg.pii_threshold - 0.85).abs() < 1e-6);
335    }
336
337    #[test]
338    fn deserialize_sha256_fields() {
339        let toml = r#"
340            injection_model_sha256 = "abc123"
341            pii_model_sha256 = "def456"
342        "#;
343        let cfg: ClassifiersConfig = toml::from_str(toml).unwrap();
344        assert_eq!(cfg.injection_model_sha256.as_deref(), Some("abc123"));
345        assert_eq!(cfg.pii_model_sha256.as_deref(), Some("def456"));
346    }
347
348    #[test]
349    fn serialize_roundtrip() {
350        let original = ClassifiersConfig {
351            enabled: true,
352            timeout_ms: 3000,
353            hf_token: Some("hf_test_token".into()),
354            scan_user_input: true,
355            injection_model: "org/model".into(),
356            enforcement_mode: InjectionEnforcementMode::Block,
357            injection_threshold_soft: 0.45,
358            injection_threshold: 0.75,
359            injection_model_sha256: Some("deadbeef".into()),
360            three_class_model: Some("org/three-class".into()),
361            three_class_threshold: 0.65,
362            three_class_model_sha256: Some("abc456".into()),
363            pii_enabled: true,
364            pii_model: "org/pii-model".into(),
365            pii_threshold: 0.80,
366            pii_model_sha256: None,
367            pii_ner_max_chars: 4096,
368            pii_ner_allowlist: vec!["MyProject".into(), "Rust".into()],
369            pii_ner_circuit_breaker: 3,
370        };
371        let serialized = toml::to_string(&original).unwrap();
372        let deserialized: ClassifiersConfig = toml::from_str(&serialized).unwrap();
373        assert_eq!(original, deserialized);
374    }
375
376    #[test]
377    fn dual_threshold_deserialization() {
378        let toml = r"
379            injection_threshold_soft = 0.4
380            injection_threshold = 0.85
381        ";
382        let cfg: ClassifiersConfig = toml::from_str(toml).unwrap();
383        assert!((cfg.injection_threshold_soft - 0.4).abs() < 1e-6);
384        assert!((cfg.injection_threshold - 0.85).abs() < 1e-6);
385    }
386
387    #[test]
388    fn soft_threshold_defaults_when_only_hard_provided() {
389        let toml = "injection_threshold = 0.9";
390        let cfg: ClassifiersConfig = toml::from_str(toml).unwrap();
391        assert!((cfg.injection_threshold_soft - 0.5).abs() < 1e-6);
392        assert!((cfg.injection_threshold - 0.9).abs() < 1e-6);
393    }
394
395    #[test]
396    fn partial_override_timeout_only() {
397        let toml = "timeout_ms = 1000";
398        let cfg: ClassifiersConfig = toml::from_str(toml).unwrap();
399        assert!(!cfg.enabled);
400        assert_eq!(cfg.timeout_ms, 1000);
401        assert_eq!(
402            cfg.injection_model,
403            "protectai/deberta-v3-small-prompt-injection-v2"
404        );
405        assert!((cfg.injection_threshold_soft - 0.5).abs() < 1e-6);
406        assert!((cfg.injection_threshold - 0.95).abs() < 1e-6);
407    }
408
409    #[test]
410    fn enforcement_mode_warn_is_default() {
411        let cfg: ClassifiersConfig = toml::from_str("").unwrap();
412        assert_eq!(cfg.enforcement_mode, InjectionEnforcementMode::Warn);
413    }
414
415    #[test]
416    fn enforcement_mode_block_roundtrip() {
417        let toml = r#"enforcement_mode = "block""#;
418        let cfg: ClassifiersConfig = toml::from_str(toml).unwrap();
419        assert_eq!(cfg.enforcement_mode, InjectionEnforcementMode::Block);
420        let back = toml::to_string(&cfg).unwrap();
421        let cfg2: ClassifiersConfig = toml::from_str(&back).unwrap();
422        assert_eq!(cfg2.enforcement_mode, InjectionEnforcementMode::Block);
423    }
424
425    #[test]
426    fn threshold_validation_rejects_zero() {
427        let result: Result<ClassifiersConfig, _> = toml::from_str("injection_threshold = 0.0");
428        assert!(result.is_err());
429    }
430
431    #[test]
432    fn threshold_validation_rejects_above_one() {
433        let result: Result<ClassifiersConfig, _> = toml::from_str("injection_threshold = 1.1");
434        assert!(result.is_err());
435    }
436
437    #[test]
438    fn threshold_validation_accepts_exactly_one() {
439        let cfg: ClassifiersConfig = toml::from_str("injection_threshold = 1.0").unwrap();
440        assert!((cfg.injection_threshold - 1.0).abs() < 1e-6);
441    }
442
443    #[test]
444    fn threshold_validation_soft_rejects_zero() {
445        let result: Result<ClassifiersConfig, _> = toml::from_str("injection_threshold_soft = 0.0");
446        assert!(result.is_err());
447    }
448
449    #[test]
450    fn three_class_model_roundtrip() {
451        let toml = r#"
452            three_class_model = "org/align-sentinel"
453            three_class_threshold = 0.65
454            three_class_model_sha256 = "aabbcc"
455        "#;
456        let cfg: ClassifiersConfig = toml::from_str(toml).unwrap();
457        assert_eq!(cfg.three_class_model.as_deref(), Some("org/align-sentinel"));
458        assert!((cfg.three_class_threshold - 0.65).abs() < 1e-6);
459        assert_eq!(cfg.three_class_model_sha256.as_deref(), Some("aabbcc"));
460    }
461
462    #[test]
463    fn pii_ner_allowlist_default_entries() {
464        let cfg = ClassifiersConfig::default();
465        assert!(cfg.pii_ner_allowlist.contains(&"Zeph".to_owned()));
466        assert!(cfg.pii_ner_allowlist.contains(&"Rust".to_owned()));
467        assert!(cfg.pii_ner_allowlist.contains(&"OpenAI".to_owned()));
468        assert!(cfg.pii_ner_allowlist.contains(&"Ollama".to_owned()));
469        assert!(cfg.pii_ner_allowlist.contains(&"Claude".to_owned()));
470    }
471
472    #[test]
473    fn pii_ner_allowlist_configurable() {
474        let toml = r#"pii_ner_allowlist = ["MyProject", "AcmeCorp"]"#;
475        let cfg: ClassifiersConfig = toml::from_str(toml).unwrap();
476        assert_eq!(cfg.pii_ner_allowlist, vec!["MyProject", "AcmeCorp"]);
477    }
478
479    #[test]
480    fn pii_ner_allowlist_empty_disables() {
481        let toml = "pii_ner_allowlist = []";
482        let cfg: ClassifiersConfig = toml::from_str(toml).unwrap();
483        assert!(cfg.pii_ner_allowlist.is_empty());
484    }
485
486    #[test]
487    fn three_class_threshold_validation_rejects_zero() {
488        let result: Result<ClassifiersConfig, _> = toml::from_str("three_class_threshold = 0.0");
489        assert!(result.is_err());
490    }
491
492    #[test]
493    fn pii_ner_circuit_breaker_default() {
494        let cfg = ClassifiersConfig::default();
495        assert_eq!(cfg.pii_ner_circuit_breaker, 2);
496    }
497
498    #[test]
499    fn pii_ner_circuit_breaker_configurable() {
500        let cfg: ClassifiersConfig = toml::from_str("pii_ner_circuit_breaker = 5").unwrap();
501        assert_eq!(cfg.pii_ner_circuit_breaker, 5);
502    }
503
504    #[test]
505    fn pii_ner_circuit_breaker_zero_disables() {
506        let cfg: ClassifiersConfig = toml::from_str("pii_ner_circuit_breaker = 0").unwrap();
507        assert_eq!(cfg.pii_ner_circuit_breaker, 0);
508    }
509
510    #[test]
511    fn pii_ner_circuit_breaker_missing_uses_default() {
512        let cfg: ClassifiersConfig = toml::from_str("").unwrap();
513        assert_eq!(cfg.pii_ner_circuit_breaker, 2);
514    }
515}