Skip to main content

zeph_config/
sanitizer.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use crate::providers::ProviderName;
5use serde::{Deserialize, Serialize};
6
7use crate::defaults::default_true;
8
9// ---------------------------------------------------------------------------
10// ContentIsolationConfig
11// ---------------------------------------------------------------------------
12
13fn default_max_content_size() -> usize {
14    65_536
15}
16
17/// Configuration for the embedding anomaly guard, nested under
18/// `[security.content_isolation.embedding_guard]`.
19#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
20pub struct EmbeddingGuardConfig {
21    /// Enable embedding-based anomaly detection (default: false — opt-in).
22    #[serde(default)]
23    pub enabled: bool,
24    /// Cosine distance threshold above which outputs are flagged as anomalous.
25    #[serde(
26        default = "default_embedding_threshold",
27        deserialize_with = "crate::de_helpers::de_unit_open"
28    )]
29    pub threshold: f64,
30    /// Minimum clean samples before centroid-based detection activates.
31    /// Before this count, regex fallback is used instead.
32    #[serde(
33        default = "default_embedding_min_samples",
34        deserialize_with = "validate_min_samples"
35    )]
36    pub min_samples: usize,
37    /// EMA alpha floor for centroid updates after stabilization (n >= `min_samples`).
38    ///
39    /// Once the centroid has accumulated `min_samples` clean outputs, each new sample
40    /// can shift it by at most this fraction. Lower values make the centroid more
41    /// resistant to slow drift attacks but slower to adapt to legitimate distribution
42    /// changes. Default: 0.01 (1% per sample).
43    #[serde(default = "default_ema_floor")]
44    pub ema_floor: f32,
45}
46
47fn validate_min_samples<'de, D>(deserializer: D) -> Result<usize, D::Error>
48where
49    D: serde::Deserializer<'de>,
50{
51    let value = <usize as serde::Deserialize>::deserialize(deserializer)?;
52    if value == 0 {
53        return Err(serde::de::Error::custom(
54            "embedding_guard.min_samples must be >= 1",
55        ));
56    }
57    Ok(value)
58}
59
60fn default_embedding_threshold() -> f64 {
61    0.35
62}
63
64fn default_embedding_min_samples() -> usize {
65    10
66}
67
68fn default_ema_floor() -> f32 {
69    0.01
70}
71
72impl Default for EmbeddingGuardConfig {
73    fn default() -> Self {
74        Self {
75            enabled: false,
76            threshold: default_embedding_threshold(),
77            min_samples: default_embedding_min_samples(),
78            ema_floor: default_ema_floor(),
79        }
80    }
81}
82
83/// Configuration for the content isolation pipeline, nested under
84/// `[security.content_isolation]` in the agent config file.
85#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
86#[allow(clippy::struct_excessive_bools)] // config struct — boolean flags are idiomatic for TOML-deserialized configuration
87pub struct ContentIsolationConfig {
88    /// When `false`, the sanitizer is a no-op: content passes through unchanged.
89    #[serde(default = "default_true")]
90    pub enabled: bool,
91
92    /// Maximum byte length of untrusted content before truncation.
93    #[serde(default = "default_max_content_size")]
94    pub max_content_size: usize,
95
96    /// When `true`, injection patterns detected in content are recorded as
97    /// flags and a warning is prepended to the spotlighting wrapper.
98    #[serde(default = "default_true")]
99    pub flag_injection_patterns: bool,
100
101    /// When `true`, untrusted content is wrapped in spotlighting XML delimiters
102    /// that instruct the LLM to treat the enclosed text as data, not instructions.
103    #[serde(default = "default_true")]
104    pub spotlight_untrusted: bool,
105
106    /// Quarantine summarizer configuration.
107    #[serde(default)]
108    pub quarantine: QuarantineConfig,
109
110    /// Embedding anomaly guard configuration.
111    #[serde(default)]
112    pub embedding_guard: EmbeddingGuardConfig,
113
114    /// When `true`, MCP tool results flowing through ACP-serving sessions receive
115    /// unconditional quarantine summarization and cross-boundary audit log entries.
116    /// This prevents confused-deputy attacks where untrusted MCP output influences
117    /// responses served to ACP clients (e.g. IDE integrations).
118    #[serde(default = "default_true")]
119    pub mcp_to_acp_boundary: bool,
120
121    /// NLI entailment check stage configuration.
122    #[serde(default)]
123    pub nli: NliConfig,
124
125    /// PAAC secret placeholder masking configuration.
126    #[serde(default)]
127    pub secret_masking: SecretMaskingConfig,
128}
129
130impl Default for ContentIsolationConfig {
131    fn default() -> Self {
132        Self {
133            enabled: true,
134            max_content_size: default_max_content_size(),
135            flag_injection_patterns: true,
136            spotlight_untrusted: true,
137            quarantine: QuarantineConfig::default(),
138            embedding_guard: EmbeddingGuardConfig::default(),
139            mcp_to_acp_boundary: true,
140            nli: NliConfig::default(),
141            secret_masking: SecretMaskingConfig::default(),
142        }
143    }
144}
145
146/// Configuration for the SONAR NLI entailment check stage, nested under
147/// `[security.content_isolation.nli]` in the agent config file.
148///
149/// When `enabled = false` (the default), the NLI stage is skipped entirely.
150#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
151pub struct NliConfig {
152    /// Enable NLI entailment-based injection detection (default: false — opt-in).
153    #[serde(default)]
154    pub enabled: bool,
155
156    /// Provider name from `[[llm.providers]]` to use for NLI inference.
157    ///
158    /// An empty [`ProviderName`] falls back to the default provider. Prefer a fast, cheap model.
159    #[serde(default)]
160    pub provider: ProviderName,
161
162    /// Entailment score threshold above which content is flagged (default: 0.75).
163    #[serde(default = "default_nli_threshold")]
164    pub threshold: f32,
165
166    /// Maximum milliseconds to wait for the NLI provider response (default: 5000).
167    #[serde(default = "default_nli_timeout_ms")]
168    pub timeout_ms: u64,
169
170    /// Maximum characters of content sent to the NLI provider (default: 2048).
171    #[serde(default = "default_nli_max_content_len")]
172    pub max_content_len: usize,
173}
174
175fn default_nli_threshold() -> f32 {
176    0.75
177}
178
179fn default_nli_timeout_ms() -> u64 {
180    5000
181}
182
183fn default_nli_max_content_len() -> usize {
184    2048
185}
186
187impl Default for NliConfig {
188    fn default() -> Self {
189        Self {
190            enabled: false,
191            provider: ProviderName::default(),
192            threshold: default_nli_threshold(),
193            timeout_ms: default_nli_timeout_ms(),
194            max_content_len: default_nli_max_content_len(),
195        }
196    }
197}
198
199/// Configuration for PAAC secret placeholder masking, nested under
200/// `[security.content_isolation.secret_masking]` in the agent config file.
201///
202/// When `enabled = false` (the default), vault secrets are not masked.
203#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
204pub struct SecretMaskingConfig {
205    /// Enable secret placeholder masking (default: false — opt-in).
206    #[serde(default)]
207    pub enabled: bool,
208
209    /// Minimum secret byte length to be eligible for masking (default: 8).
210    ///
211    /// Secrets shorter than this value are not substituted to avoid false matches
212    /// on common short strings.
213    #[serde(default = "default_min_secret_len")]
214    pub min_secret_len: usize,
215}
216
217fn default_min_secret_len() -> usize {
218    8
219}
220
221impl Default for SecretMaskingConfig {
222    fn default() -> Self {
223        Self {
224            enabled: false,
225            min_secret_len: default_min_secret_len(),
226        }
227    }
228}
229
230/// Configuration for the quarantine summarizer, nested under
231/// `[security.content_isolation.quarantine]` in the agent config file.
232#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
233pub struct QuarantineConfig {
234    /// When `false`, quarantine summarization is disabled entirely.
235    #[serde(default)]
236    pub enabled: bool,
237
238    /// Source kinds to route through the quarantine LLM.
239    #[serde(default = "default_quarantine_sources")]
240    pub sources: Vec<String>,
241
242    /// Provider name passed to `create_named_provider`.
243    #[serde(default = "default_quarantine_model")]
244    pub model: String,
245
246    /// Maximum time in milliseconds to wait for the quarantine LLM to respond.
247    ///
248    /// When the LLM does not respond within this window, `extract_facts` returns a timeout
249    /// error so the agent can recover rather than stalling indefinitely.
250    /// Defaults to 30 000 ms (30 s).
251    #[serde(default = "default_quarantine_timeout_ms")]
252    pub timeout_ms: u64,
253}
254
255fn default_quarantine_sources() -> Vec<String> {
256    vec!["web_scrape".to_owned(), "a2a_message".to_owned()]
257}
258
259fn default_quarantine_model() -> String {
260    "claude".to_owned()
261}
262
263fn default_quarantine_timeout_ms() -> u64 {
264    30_000
265}
266
267impl Default for QuarantineConfig {
268    fn default() -> Self {
269        Self {
270            enabled: false,
271            sources: default_quarantine_sources(),
272            model: default_quarantine_model(),
273            timeout_ms: default_quarantine_timeout_ms(),
274        }
275    }
276}
277
278// ---------------------------------------------------------------------------
279// ExfiltrationGuardConfig
280// ---------------------------------------------------------------------------
281
282/// Configuration for exfiltration guards, nested under
283/// `[security.exfiltration_guard]` in the agent config file.
284#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
285pub struct ExfiltrationGuardConfig {
286    /// Strip external markdown images from LLM output to prevent pixel-tracking exfiltration.
287    #[serde(default = "default_true")]
288    pub block_markdown_images: bool,
289
290    /// Cross-reference tool call arguments against URLs seen in flagged untrusted content.
291    #[serde(default = "default_true")]
292    pub validate_tool_urls: bool,
293
294    /// Skip Qdrant embedding for messages that contained injection-flagged content.
295    #[serde(default = "default_true")]
296    pub guard_memory_writes: bool,
297}
298
299impl Default for ExfiltrationGuardConfig {
300    fn default() -> Self {
301        Self {
302            block_markdown_images: true,
303            validate_tool_urls: true,
304            guard_memory_writes: true,
305        }
306    }
307}
308
309// ---------------------------------------------------------------------------
310// MemoryWriteValidationConfig
311// ---------------------------------------------------------------------------
312
313fn default_max_content_bytes() -> usize {
314    4096
315}
316
317fn default_max_entity_name_bytes() -> usize {
318    256
319}
320
321fn default_min_entity_name_bytes() -> usize {
322    3
323}
324
325fn default_max_fact_bytes() -> usize {
326    1024
327}
328
329fn default_max_entities() -> usize {
330    50
331}
332
333fn default_max_edges() -> usize {
334    100
335}
336
337/// Configuration for memory write validation, nested under `[security.memory_validation]`.
338///
339/// Enabled by default with conservative limits.
340#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
341pub struct MemoryWriteValidationConfig {
342    /// Master switch. When `false`, validation is a no-op.
343    #[serde(default = "default_true")]
344    pub enabled: bool,
345    /// Maximum byte length of content passed to `memory_save`.
346    #[serde(default = "default_max_content_bytes")]
347    pub max_content_bytes: usize,
348    /// Minimum byte length of an entity name in graph extraction.
349    #[serde(default = "default_min_entity_name_bytes")]
350    pub min_entity_name_bytes: usize,
351    /// Maximum byte length of a single entity name in graph extraction.
352    #[serde(default = "default_max_entity_name_bytes")]
353    pub max_entity_name_bytes: usize,
354    /// Maximum byte length of an edge fact string in graph extraction.
355    #[serde(default = "default_max_fact_bytes")]
356    pub max_fact_bytes: usize,
357    /// Maximum number of entities allowed per graph extraction result.
358    #[serde(default = "default_max_entities")]
359    pub max_entities_per_extraction: usize,
360    /// Maximum number of edges allowed per graph extraction result.
361    #[serde(default = "default_max_edges")]
362    pub max_edges_per_extraction: usize,
363    /// Forbidden substring patterns.
364    #[serde(default)]
365    pub forbidden_content_patterns: Vec<String>,
366}
367
368impl Default for MemoryWriteValidationConfig {
369    fn default() -> Self {
370        Self {
371            enabled: true,
372            max_content_bytes: default_max_content_bytes(),
373            min_entity_name_bytes: default_min_entity_name_bytes(),
374            max_entity_name_bytes: default_max_entity_name_bytes(),
375            max_fact_bytes: default_max_fact_bytes(),
376            max_entities_per_extraction: default_max_entities(),
377            max_edges_per_extraction: default_max_edges(),
378            forbidden_content_patterns: Vec::new(),
379        }
380    }
381}
382
383// ---------------------------------------------------------------------------
384// PiiFilterConfig
385// ---------------------------------------------------------------------------
386
387/// A single user-defined PII pattern.
388#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
389pub struct CustomPiiPattern {
390    /// Human-readable name used in the replacement label.
391    pub name: String,
392    /// Regular expression pattern.
393    pub pattern: String,
394    /// Replacement text. Defaults to `[PII:custom]`.
395    #[serde(default = "default_custom_replacement")]
396    pub replacement: String,
397}
398
399fn default_custom_replacement() -> String {
400    "[PII:custom]".to_owned()
401}
402
403/// Configuration for the PII filter, nested under `[security.pii_filter]` in the config file.
404///
405/// Disabled by default — opt-in to avoid unexpected data loss.
406#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
407#[allow(clippy::struct_excessive_bools)] // config struct — boolean flags are idiomatic for TOML-deserialized configuration
408pub struct PiiFilterConfig {
409    /// Master switch. When `false`, the filter is a no-op.
410    #[serde(default)]
411    pub enabled: bool,
412    /// Scrub email addresses.
413    #[serde(default = "default_true")]
414    pub filter_email: bool,
415    /// Scrub US phone numbers.
416    #[serde(default = "default_true")]
417    pub filter_phone: bool,
418    /// Scrub US Social Security Numbers.
419    #[serde(default = "default_true")]
420    pub filter_ssn: bool,
421    /// Scrub credit card numbers (16-digit patterns).
422    #[serde(default = "default_true")]
423    pub filter_credit_card: bool,
424    /// Scrub personal names via a capitalized-word-sequence heuristic: 2+ consecutive
425    /// ASCII Titlecase tokens excluding a stoplist of common capitalized non-name words.
426    /// Compensating control for weak NER-model recall on free-text names (#5530).
427    ///
428    /// Defaults to `false` (opt-in), unlike the other `filter_*` flags: this is a high-recall,
429    /// lower-precision heuristic that also flags common two-word technical/product terms (e.g.
430    /// `"Docker Compose"`, `"Pull Request"`, `"New York"`) as candidate names, so it is not
431    /// force-enabled for existing `pii_filter.enabled = true` deployments.
432    #[serde(default)]
433    pub filter_names: bool,
434    /// Custom regex patterns to add on top of the built-ins.
435    #[serde(default)]
436    pub custom_patterns: Vec<CustomPiiPattern>,
437}
438
439impl Default for PiiFilterConfig {
440    fn default() -> Self {
441        Self {
442            enabled: false,
443            filter_email: true,
444            filter_phone: true,
445            filter_ssn: true,
446            filter_credit_card: true,
447            filter_names: false,
448            custom_patterns: Vec::new(),
449        }
450    }
451}
452
453// ---------------------------------------------------------------------------
454// GuardrailConfig
455// ---------------------------------------------------------------------------
456
457/// What happens when the guardrail flags input.
458#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)]
459#[serde(rename_all = "lowercase")]
460#[non_exhaustive]
461pub enum GuardrailAction {
462    /// Block the input and return an error message to the user.
463    #[default]
464    Block,
465    /// Allow the input but emit a warning message.
466    Warn,
467}
468
469/// Behavior on timeout or LLM error.
470#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)]
471#[serde(rename_all = "lowercase")]
472#[non_exhaustive]
473pub enum GuardrailFailStrategy {
474    /// Block input on timeout/error (safe default for security-sensitive deployments).
475    #[default]
476    Closed,
477    /// Allow input on timeout/error (for availability-sensitive deployments).
478    Open,
479}
480
481/// Configuration for the LLM-based guardrail, nested under `[security.guardrail]`.
482#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
483pub struct GuardrailConfig {
484    /// Enable the guardrail (default: false).
485    #[serde(default)]
486    pub enabled: bool,
487    /// Provider to use for guardrail classification (e.g. `"ollama"`, `"claude"`).
488    #[serde(default)]
489    pub provider: Option<String>,
490    /// Model to use (e.g. `"llama-guard-3:1b"`).
491    #[serde(default)]
492    pub model: Option<String>,
493    /// Timeout for each guardrail LLM call in milliseconds (default: 500).
494    #[serde(default = "default_guardrail_timeout_ms")]
495    pub timeout_ms: u64,
496    /// Action to take when a message is flagged (default: block).
497    #[serde(default)]
498    pub action: GuardrailAction,
499    /// What to do on timeout or LLM error (default: closed — block).
500    #[serde(default = "default_fail_strategy")]
501    pub fail_strategy: GuardrailFailStrategy,
502    /// When `true`, also scan tool outputs before they enter message history (default: false).
503    #[serde(default)]
504    pub scan_tool_output: bool,
505    /// Maximum number of characters to send to the guard model (default: 4096).
506    #[serde(default = "default_max_input_chars")]
507    pub max_input_chars: usize,
508}
509fn default_guardrail_timeout_ms() -> u64 {
510    500
511}
512fn default_max_input_chars() -> usize {
513    4096
514}
515fn default_fail_strategy() -> GuardrailFailStrategy {
516    GuardrailFailStrategy::Closed
517}
518impl Default for GuardrailConfig {
519    fn default() -> Self {
520        Self {
521            enabled: false,
522            provider: None,
523            model: None,
524            timeout_ms: default_guardrail_timeout_ms(),
525            action: GuardrailAction::default(),
526            fail_strategy: default_fail_strategy(),
527            scan_tool_output: false,
528            max_input_chars: default_max_input_chars(),
529        }
530    }
531}
532
533// ---------------------------------------------------------------------------
534// ResponseVerificationConfig
535// ---------------------------------------------------------------------------
536
537/// Configuration for post-LLM response verification, nested under
538/// `[security.response_verification]` in the agent config file.
539///
540/// Scans LLM responses for injected instruction patterns before tool dispatch.
541/// This is defense-in-depth layer 3 (after input sanitization and pre-execution verification).
542#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
543pub struct ResponseVerificationConfig {
544    /// Enable post-LLM response verification (default: true).
545    #[serde(default = "default_true")]
546    pub enabled: bool,
547    /// Block tool dispatch when injection patterns are detected (default: false).
548    ///
549    /// When `false`, flagged responses are logged and shown in the TUI SEC panel
550    /// but still delivered. When `true`, the response is suppressed and the user
551    /// is notified.
552    #[serde(default)]
553    pub block_on_detection: bool,
554    /// Optional LLM provider for async deep verification of flagged responses.
555    ///
556    /// When set: suspicious responses are delivered immediately with a `[FLAGGED]`
557    /// annotation, and background LLM verification runs asynchronously. The verifier
558    /// receives a sanitized summary (via `QuarantinedSummarizer`) to prevent recursive
559    /// injection. Empty string = disabled (regex-only verification).
560    #[serde(default)]
561    pub verifier_provider: ProviderName,
562}
563
564impl Default for ResponseVerificationConfig {
565    fn default() -> Self {
566        Self {
567            enabled: true,
568            block_on_detection: false,
569            verifier_provider: ProviderName::default(),
570        }
571    }
572}
573
574#[cfg(test)]
575mod tests {
576    use super::*;
577
578    #[test]
579    fn content_isolation_default_mcp_to_acp_boundary_true() {
580        let cfg = ContentIsolationConfig::default();
581        assert!(cfg.mcp_to_acp_boundary);
582    }
583
584    #[test]
585    fn content_isolation_deserialize_mcp_to_acp_boundary_false() {
586        let toml = r"
587            mcp_to_acp_boundary = false
588        ";
589        let cfg: ContentIsolationConfig = toml::from_str(toml).unwrap();
590        assert!(!cfg.mcp_to_acp_boundary);
591    }
592
593    #[test]
594    fn content_isolation_deserialize_absent_defaults_true() {
595        let cfg: ContentIsolationConfig = toml::from_str("").unwrap();
596        assert!(cfg.mcp_to_acp_boundary);
597    }
598
599    fn de_guard(toml: &str) -> Result<EmbeddingGuardConfig, toml::de::Error> {
600        toml::from_str(toml)
601    }
602
603    #[test]
604    fn threshold_valid() {
605        let cfg = de_guard("threshold = 0.35\nmin_samples = 5").unwrap();
606        assert!((cfg.threshold - 0.35).abs() < f64::EPSILON);
607    }
608
609    #[test]
610    fn threshold_one_valid() {
611        let cfg = de_guard("threshold = 1.0\nmin_samples = 1").unwrap();
612        assert!((cfg.threshold - 1.0).abs() < f64::EPSILON);
613    }
614
615    #[test]
616    fn threshold_zero_rejected() {
617        assert!(de_guard("threshold = 0.0\nmin_samples = 1").is_err());
618    }
619
620    #[test]
621    fn threshold_above_one_rejected() {
622        assert!(de_guard("threshold = 1.5\nmin_samples = 1").is_err());
623    }
624
625    #[test]
626    fn threshold_negative_rejected() {
627        assert!(de_guard("threshold = -0.1\nmin_samples = 1").is_err());
628    }
629
630    #[test]
631    fn min_samples_zero_rejected() {
632        assert!(de_guard("threshold = 0.35\nmin_samples = 0").is_err());
633    }
634
635    #[test]
636    fn min_samples_one_valid() {
637        let cfg = de_guard("threshold = 0.35\nmin_samples = 1").unwrap();
638        assert_eq!(cfg.min_samples, 1);
639    }
640}
641
642// ---------------------------------------------------------------------------
643// CausalIpiConfig
644// ---------------------------------------------------------------------------
645
646fn default_causal_threshold() -> f32 {
647    0.7
648}
649
650fn default_probe_max_tokens() -> u32 {
651    100
652}
653
654fn default_probe_timeout_ms() -> u64 {
655    3000
656}
657
658/// Temporal causal IPI analysis at tool-return boundaries.
659///
660/// When enabled, the agent generates behavioral probes before and after tool batch dispatch
661/// and compares them to detect behavioral deviation caused by injected instructions in
662/// tool outputs. Probes are per-batch (2 LLM calls total), not per individual tool.
663///
664/// Config section: `[security.causal_ipi]`
665#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
666pub struct CausalIpiConfig {
667    /// Master switch. Default: false (opt-in).
668    #[serde(default)]
669    pub enabled: bool,
670
671    /// Causal attribution score threshold for flagging. Range: (0.0, 1.0]. Default 0.7.
672    ///
673    /// Scores above this value trigger a WARN log, metric increment, and `SecurityEvent`.
674    /// Content is never blocked — this is an observation layer only.
675    #[serde(
676        default = "default_causal_threshold",
677        deserialize_with = "crate::de_helpers::de_unit_open"
678    )]
679    pub threshold: f32,
680
681    /// LLM provider name from `[[llm.providers]]` for probe calls.
682    ///
683    /// Should reference a fast/cheap provider — probes run on every tool batch return.
684    /// When `None`, falls back to the agent's default provider.
685    #[serde(default)]
686    pub provider: Option<String>,
687
688    /// Maximum tokens for each probe response. Limits cost per probe call. Default: 100.
689    ///
690    /// Two probes per batch = max `2 * probe_max_tokens` output tokens per tool batch.
691    #[serde(default = "default_probe_max_tokens")]
692    pub probe_max_tokens: u32,
693
694    /// Timeout in milliseconds for each individual probe LLM call. Default: 3000.
695    ///
696    /// On timeout: WARN log, skip causal analysis for the batch (never block).
697    #[serde(default = "default_probe_timeout_ms")]
698    pub probe_timeout_ms: u64,
699
700    /// Shadow memory configuration for cross-turn trajectory analysis.
701    #[serde(default)]
702    pub shadow_memory: ShadowMemoryConfig,
703}
704
705impl Default for CausalIpiConfig {
706    fn default() -> Self {
707        Self {
708            enabled: false,
709            threshold: default_causal_threshold(),
710            provider: None,
711            probe_max_tokens: default_probe_max_tokens(),
712            probe_timeout_ms: default_probe_timeout_ms(),
713            shadow_memory: ShadowMemoryConfig::default(),
714        }
715    }
716}
717
718// ---------------------------------------------------------------------------
719// ShadowMemoryConfig
720// ---------------------------------------------------------------------------
721
722fn default_shadow_window() -> usize {
723    8
724}
725
726fn default_shadow_max_events() -> usize {
727    64
728}
729
730fn default_shadow_drift_threshold() -> f32 {
731    0.6
732}
733
734fn validate_shadow_window<'de, D>(deserializer: D) -> Result<usize, D::Error>
735where
736    D: serde::Deserializer<'de>,
737{
738    let value = <usize as serde::Deserialize>::deserialize(deserializer)?;
739    if value == 0 {
740        return Err(serde::de::Error::custom(
741            "shadow_memory.window_size must be >= 1",
742        ));
743    }
744    Ok(value)
745}
746
747fn validate_shadow_max_events<'de, D>(deserializer: D) -> Result<usize, D::Error>
748where
749    D: serde::Deserializer<'de>,
750{
751    let value = <usize as serde::Deserialize>::deserialize(deserializer)?;
752    if value == 0 {
753        return Err(serde::de::Error::custom(
754            "shadow_memory.max_events must be >= 1",
755        ));
756    }
757    Ok(value)
758}
759
760/// Per-session append-only event store for cross-turn trajectory analysis.
761///
762/// Detects multi-turn attacks that distribute payload across several turns —
763/// invisible to the stateless [`CausalIpiConfig`] single-batch analysis.
764///
765/// Config section: `[security.causal_ipi.shadow_memory]`
766///
767/// # Examples
768///
769/// ```toml
770/// [security.causal_ipi.shadow_memory]
771/// enabled = true
772/// window_size = 8
773/// max_events = 64
774/// drift_threshold = 0.6
775/// ```
776#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
777pub struct ShadowMemoryConfig {
778    /// Enable shadow memory trajectory tracking. Default: false.
779    #[serde(default)]
780    pub enabled: bool,
781
782    /// Sliding window size for drift computation. Must be >= 1. Default: 8.
783    #[serde(
784        default = "default_shadow_window",
785        deserialize_with = "validate_shadow_window"
786    )]
787    pub window_size: usize,
788
789    /// Maximum events retained before oldest are evicted. Must be >= 1. Default: 64.
790    #[serde(
791        default = "default_shadow_max_events",
792        deserialize_with = "validate_shadow_max_events"
793    )]
794    pub max_events: usize,
795
796    /// Goal drift score threshold for flagging. Range: (0.0, 1.0]. Default: 0.6.
797    #[serde(
798        default = "default_shadow_drift_threshold",
799        deserialize_with = "crate::de_helpers::de_unit_open"
800    )]
801    pub drift_threshold: f32,
802}
803
804impl Default for ShadowMemoryConfig {
805    fn default() -> Self {
806        Self {
807            enabled: false,
808            window_size: default_shadow_window(),
809            max_events: default_shadow_max_events(),
810            drift_threshold: default_shadow_drift_threshold(),
811        }
812    }
813}
814
815#[cfg(test)]
816mod causal_ipi_tests {
817    use super::*;
818
819    #[test]
820    fn causal_ipi_defaults() {
821        let cfg = CausalIpiConfig::default();
822        assert!(!cfg.enabled);
823        assert!((cfg.threshold - 0.7).abs() < 1e-6);
824        assert!(cfg.provider.is_none());
825        assert_eq!(cfg.probe_max_tokens, 100);
826        assert_eq!(cfg.probe_timeout_ms, 3000);
827    }
828
829    #[test]
830    fn causal_ipi_deserialize_enabled() {
831        let toml = r#"
832            enabled = true
833            threshold = 0.8
834            provider = "fast"
835            probe_max_tokens = 150
836            probe_timeout_ms = 5000
837        "#;
838        let cfg: CausalIpiConfig = toml::from_str(toml).unwrap();
839        assert!(cfg.enabled);
840        assert!((cfg.threshold - 0.8).abs() < 1e-6);
841        assert_eq!(cfg.provider.as_deref(), Some("fast"));
842        assert_eq!(cfg.probe_max_tokens, 150);
843        assert_eq!(cfg.probe_timeout_ms, 5000);
844    }
845
846    #[test]
847    fn causal_ipi_threshold_zero_rejected() {
848        let result: Result<CausalIpiConfig, _> = toml::from_str("threshold = 0.0");
849        assert!(result.is_err());
850    }
851
852    #[test]
853    fn causal_ipi_threshold_above_one_rejected() {
854        let result: Result<CausalIpiConfig, _> = toml::from_str("threshold = 1.1");
855        assert!(result.is_err());
856    }
857
858    #[test]
859    fn causal_ipi_threshold_exactly_one_accepted() {
860        let cfg: CausalIpiConfig = toml::from_str("threshold = 1.0").unwrap();
861        assert!((cfg.threshold - 1.0).abs() < 1e-6);
862    }
863}
864
865#[cfg(test)]
866mod shadow_memory_config_tests {
867    use super::*;
868
869    #[test]
870    fn shadow_memory_defaults() {
871        let cfg = ShadowMemoryConfig::default();
872        assert!(!cfg.enabled);
873        assert_eq!(cfg.window_size, 8);
874        assert_eq!(cfg.max_events, 64);
875        assert!((cfg.drift_threshold - 0.6).abs() < 1e-6);
876    }
877
878    #[test]
879    fn shadow_memory_window_zero_rejected() {
880        let result: Result<ShadowMemoryConfig, _> = toml::from_str("window_size = 0");
881        assert!(result.is_err());
882    }
883
884    #[test]
885    fn shadow_memory_max_events_zero_rejected() {
886        let result: Result<ShadowMemoryConfig, _> = toml::from_str("max_events = 0");
887        assert!(result.is_err());
888    }
889
890    #[test]
891    fn shadow_memory_drift_threshold_zero_rejected() {
892        let result: Result<ShadowMemoryConfig, _> = toml::from_str("drift_threshold = 0.0");
893        assert!(result.is_err());
894    }
895
896    #[test]
897    fn shadow_memory_drift_threshold_above_one_rejected() {
898        let result: Result<ShadowMemoryConfig, _> = toml::from_str("drift_threshold = 1.1");
899        assert!(result.is_err());
900    }
901
902    #[test]
903    fn shadow_memory_drift_threshold_exactly_one_accepted() {
904        let cfg: ShadowMemoryConfig = toml::from_str("drift_threshold = 1.0").unwrap();
905        assert!((cfg.drift_threshold - 1.0).abs() < 1e-6);
906    }
907
908    #[test]
909    fn shadow_memory_full_deserialization() {
910        let toml = r"
911            enabled = true
912            window_size = 4
913            max_events = 32
914            drift_threshold = 0.8
915        ";
916        let cfg: ShadowMemoryConfig = toml::from_str(toml).unwrap();
917        assert!(cfg.enabled);
918        assert_eq!(cfg.window_size, 4);
919        assert_eq!(cfg.max_events, 32);
920        assert!((cfg.drift_threshold - 0.8).abs() < 1e-6);
921    }
922}