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/// Enabled by default: substitution is a cheap synchronous placeholder swap with no LLM
203/// call, and keeps vault-resolved secrets out of LLM payloads, `SQLite` history, and debug
204/// dumps (#6263).
205#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
206pub struct SecretMaskingConfig {
207    /// Enable secret placeholder masking (default: true).
208    #[serde(default = "default_true")]
209    pub enabled: bool,
210
211    /// Minimum secret byte length to be eligible for masking (default: 8).
212    ///
213    /// Secrets shorter than this value are not substituted to avoid false matches
214    /// on common short strings.
215    #[serde(default = "default_min_secret_len")]
216    pub min_secret_len: usize,
217}
218
219fn default_min_secret_len() -> usize {
220    8
221}
222
223impl Default for SecretMaskingConfig {
224    fn default() -> Self {
225        Self {
226            enabled: true,
227            min_secret_len: default_min_secret_len(),
228        }
229    }
230}
231
232/// Configuration for the quarantine summarizer, nested under
233/// `[security.content_isolation.quarantine]` in the agent config file.
234#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
235pub struct QuarantineConfig {
236    /// When `false`, quarantine summarization is disabled entirely.
237    #[serde(default)]
238    pub enabled: bool,
239
240    /// Source kinds to route through the quarantine LLM.
241    #[serde(default = "default_quarantine_sources")]
242    pub sources: Vec<String>,
243
244    /// Provider name passed to `create_named_provider`.
245    #[serde(default = "default_quarantine_model")]
246    pub model: String,
247
248    /// Maximum time in milliseconds to wait for the quarantine LLM to respond.
249    ///
250    /// When the LLM does not respond within this window, `extract_facts` returns a timeout
251    /// error so the agent can recover rather than stalling indefinitely.
252    /// Defaults to 30 000 ms (30 s).
253    #[serde(default = "default_quarantine_timeout_ms")]
254    pub timeout_ms: u64,
255}
256
257fn default_quarantine_sources() -> Vec<String> {
258    vec!["web_scrape".to_owned(), "a2a_message".to_owned()]
259}
260
261fn default_quarantine_model() -> String {
262    "claude".to_owned()
263}
264
265fn default_quarantine_timeout_ms() -> u64 {
266    30_000
267}
268
269impl Default for QuarantineConfig {
270    fn default() -> Self {
271        Self {
272            enabled: false,
273            sources: default_quarantine_sources(),
274            model: default_quarantine_model(),
275            timeout_ms: default_quarantine_timeout_ms(),
276        }
277    }
278}
279
280// ---------------------------------------------------------------------------
281// ExfiltrationGuardConfig
282// ---------------------------------------------------------------------------
283
284/// Configuration for exfiltration guards, nested under
285/// `[security.exfiltration_guard]` in the agent config file.
286#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
287pub struct ExfiltrationGuardConfig {
288    /// Strip external markdown images from LLM output to prevent pixel-tracking exfiltration.
289    #[serde(default = "default_true")]
290    pub block_markdown_images: bool,
291
292    /// Cross-reference tool call arguments against URLs seen in flagged untrusted content.
293    #[serde(default = "default_true")]
294    pub validate_tool_urls: bool,
295
296    /// Skip Qdrant embedding for messages that contained injection-flagged content.
297    #[serde(default = "default_true")]
298    pub guard_memory_writes: bool,
299}
300
301impl Default for ExfiltrationGuardConfig {
302    fn default() -> Self {
303        Self {
304            block_markdown_images: true,
305            validate_tool_urls: true,
306            guard_memory_writes: true,
307        }
308    }
309}
310
311// ---------------------------------------------------------------------------
312// MemoryWriteValidationConfig
313// ---------------------------------------------------------------------------
314
315fn default_max_content_bytes() -> usize {
316    4096
317}
318
319fn default_max_entity_name_bytes() -> usize {
320    256
321}
322
323fn default_min_entity_name_bytes() -> usize {
324    3
325}
326
327fn default_max_fact_bytes() -> usize {
328    1024
329}
330
331fn default_max_entities() -> usize {
332    50
333}
334
335fn default_max_edges() -> usize {
336    100
337}
338
339/// Configuration for memory write validation, nested under `[security.memory_validation]`.
340///
341/// Enabled by default with conservative limits.
342#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
343pub struct MemoryWriteValidationConfig {
344    /// Master switch. When `false`, validation is a no-op.
345    #[serde(default = "default_true")]
346    pub enabled: bool,
347    /// Maximum byte length of content passed to `memory_save`.
348    #[serde(default = "default_max_content_bytes")]
349    pub max_content_bytes: usize,
350    /// Minimum byte length of an entity name in graph extraction.
351    #[serde(default = "default_min_entity_name_bytes")]
352    pub min_entity_name_bytes: usize,
353    /// Maximum byte length of a single entity name in graph extraction.
354    #[serde(default = "default_max_entity_name_bytes")]
355    pub max_entity_name_bytes: usize,
356    /// Maximum byte length of an edge fact string in graph extraction.
357    #[serde(default = "default_max_fact_bytes")]
358    pub max_fact_bytes: usize,
359    /// Maximum number of entities allowed per graph extraction result.
360    #[serde(default = "default_max_entities")]
361    pub max_entities_per_extraction: usize,
362    /// Maximum number of edges allowed per graph extraction result.
363    #[serde(default = "default_max_edges")]
364    pub max_edges_per_extraction: usize,
365    /// Forbidden substring patterns.
366    #[serde(default)]
367    pub forbidden_content_patterns: Vec<String>,
368}
369
370impl Default for MemoryWriteValidationConfig {
371    fn default() -> Self {
372        Self {
373            enabled: true,
374            max_content_bytes: default_max_content_bytes(),
375            min_entity_name_bytes: default_min_entity_name_bytes(),
376            max_entity_name_bytes: default_max_entity_name_bytes(),
377            max_fact_bytes: default_max_fact_bytes(),
378            max_entities_per_extraction: default_max_entities(),
379            max_edges_per_extraction: default_max_edges(),
380            forbidden_content_patterns: Vec::new(),
381        }
382    }
383}
384
385// ---------------------------------------------------------------------------
386// PiiFilterConfig
387// ---------------------------------------------------------------------------
388
389/// A single user-defined PII pattern.
390#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
391pub struct CustomPiiPattern {
392    /// Human-readable name used in the replacement label.
393    pub name: String,
394    /// Regular expression pattern.
395    pub pattern: String,
396    /// Replacement text. Defaults to `[PII:custom]`.
397    #[serde(default = "default_custom_replacement")]
398    pub replacement: String,
399}
400
401fn default_custom_replacement() -> String {
402    "[PII:custom]".to_owned()
403}
404
405/// Configuration for the PII filter, nested under `[security.pii_filter]` in the config file.
406///
407/// Enabled by default: filtering is a cheap synchronous regex substitution with no LLM
408/// call, and keeps PII out of LLM payloads, `SQLite` history, and debug dumps (#6263).
409#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
410#[allow(clippy::struct_excessive_bools)] // config struct — boolean flags are idiomatic for TOML-deserialized configuration
411pub struct PiiFilterConfig {
412    /// Master switch. When `false`, the filter is a no-op. Default: `true`.
413    #[serde(default = "default_true")]
414    pub enabled: bool,
415    /// Scrub email addresses.
416    #[serde(default = "default_true")]
417    pub filter_email: bool,
418    /// Scrub US phone numbers.
419    #[serde(default = "default_true")]
420    pub filter_phone: bool,
421    /// Scrub US Social Security Numbers.
422    #[serde(default = "default_true")]
423    pub filter_ssn: bool,
424    /// Scrub credit card numbers (16-digit patterns).
425    #[serde(default = "default_true")]
426    pub filter_credit_card: bool,
427    /// Scrub personal names via a capitalized-word-sequence heuristic: 2+ consecutive
428    /// ASCII Titlecase tokens excluding a stoplist of common capitalized non-name words.
429    /// Compensating control for weak NER-model recall on free-text names (#5530).
430    ///
431    /// Defaults to `false` (opt-in), unlike the other `filter_*` flags: this is a high-recall,
432    /// lower-precision heuristic that also flags common two-word technical/product terms (e.g.
433    /// `"Docker Compose"`, `"Pull Request"`, `"New York"`) as candidate names, so it is not
434    /// force-enabled for existing `pii_filter.enabled = true` deployments.
435    #[serde(default)]
436    pub filter_names: bool,
437    /// Custom regex patterns to add on top of the built-ins.
438    #[serde(default)]
439    pub custom_patterns: Vec<CustomPiiPattern>,
440}
441
442impl Default for PiiFilterConfig {
443    fn default() -> Self {
444        Self {
445            enabled: true,
446            filter_email: true,
447            filter_phone: true,
448            filter_ssn: true,
449            filter_credit_card: true,
450            filter_names: false,
451            custom_patterns: Vec::new(),
452        }
453    }
454}
455
456// ---------------------------------------------------------------------------
457// GuardrailConfig
458// ---------------------------------------------------------------------------
459
460/// What happens when the guardrail flags input.
461#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)]
462#[serde(rename_all = "lowercase")]
463#[non_exhaustive]
464pub enum GuardrailAction {
465    /// Block the input and return an error message to the user.
466    #[default]
467    Block,
468    /// Allow the input but emit a warning message.
469    Warn,
470}
471
472/// Behavior on timeout or LLM error.
473#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)]
474#[serde(rename_all = "lowercase")]
475#[non_exhaustive]
476pub enum GuardrailFailStrategy {
477    /// Block input on timeout/error (safe default for security-sensitive deployments).
478    #[default]
479    Closed,
480    /// Allow input on timeout/error (for availability-sensitive deployments).
481    Open,
482}
483
484/// Configuration for the LLM-based guardrail, nested under `[security.guardrail]`.
485#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
486pub struct GuardrailConfig {
487    /// Enable the guardrail (default: false).
488    #[serde(default)]
489    pub enabled: bool,
490    /// Provider to use for guardrail classification (e.g. `"ollama"`, `"claude"`).
491    #[serde(default)]
492    pub provider: Option<String>,
493    /// Model to use (e.g. `"llama-guard-3:1b"`).
494    #[serde(default)]
495    pub model: Option<String>,
496    /// Timeout for each guardrail LLM call in milliseconds (default: 500).
497    #[serde(default = "default_guardrail_timeout_ms")]
498    pub timeout_ms: u64,
499    /// Action to take when a message is flagged (default: block).
500    #[serde(default)]
501    pub action: GuardrailAction,
502    /// What to do on timeout or LLM error (default: closed — block).
503    #[serde(default = "default_fail_strategy")]
504    pub fail_strategy: GuardrailFailStrategy,
505    /// When `true`, also scan tool outputs before they enter message history (default: false).
506    #[serde(default)]
507    pub scan_tool_output: bool,
508    /// Maximum number of characters to send to the guard model (default: 4096).
509    #[serde(default = "default_max_input_chars")]
510    pub max_input_chars: usize,
511}
512fn default_guardrail_timeout_ms() -> u64 {
513    500
514}
515fn default_max_input_chars() -> usize {
516    4096
517}
518fn default_fail_strategy() -> GuardrailFailStrategy {
519    GuardrailFailStrategy::Closed
520}
521impl Default for GuardrailConfig {
522    fn default() -> Self {
523        Self {
524            enabled: false,
525            provider: None,
526            model: None,
527            timeout_ms: default_guardrail_timeout_ms(),
528            action: GuardrailAction::default(),
529            fail_strategy: default_fail_strategy(),
530            scan_tool_output: false,
531            max_input_chars: default_max_input_chars(),
532        }
533    }
534}
535
536// ---------------------------------------------------------------------------
537// ResponseVerificationConfig
538// ---------------------------------------------------------------------------
539
540/// Configuration for post-LLM response verification, nested under
541/// `[security.response_verification]` in the agent config file.
542///
543/// Scans LLM responses for injected instruction patterns before tool dispatch.
544/// This is defense-in-depth layer 3 (after input sanitization and pre-execution verification).
545#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
546pub struct ResponseVerificationConfig {
547    /// Enable post-LLM response verification (default: true).
548    #[serde(default = "default_true")]
549    pub enabled: bool,
550    /// Block tool dispatch when injection patterns are detected (default: false).
551    ///
552    /// When `false`, flagged responses are logged and shown in the TUI SEC panel
553    /// but still delivered. When `true`, the response is suppressed and the user
554    /// is notified.
555    #[serde(default)]
556    pub block_on_detection: bool,
557    /// Optional LLM provider for async deep verification of flagged responses.
558    ///
559    /// When set: suspicious responses are delivered immediately with a `[FLAGGED]`
560    /// annotation, and background LLM verification runs asynchronously. The verifier
561    /// receives a sanitized summary (via `QuarantinedSummarizer`) to prevent recursive
562    /// injection. Empty string = disabled (regex-only verification).
563    #[serde(default)]
564    pub verifier_provider: ProviderName,
565}
566
567impl Default for ResponseVerificationConfig {
568    fn default() -> Self {
569        Self {
570            enabled: true,
571            block_on_detection: false,
572            verifier_provider: ProviderName::default(),
573        }
574    }
575}
576
577#[cfg(test)]
578mod tests {
579    use super::*;
580
581    #[test]
582    fn content_isolation_default_mcp_to_acp_boundary_true() {
583        let cfg = ContentIsolationConfig::default();
584        assert!(cfg.mcp_to_acp_boundary);
585    }
586
587    #[test]
588    fn content_isolation_deserialize_mcp_to_acp_boundary_false() {
589        let toml = r"
590            mcp_to_acp_boundary = false
591        ";
592        let cfg: ContentIsolationConfig = toml::from_str(toml).unwrap();
593        assert!(!cfg.mcp_to_acp_boundary);
594    }
595
596    #[test]
597    fn content_isolation_deserialize_absent_defaults_true() {
598        let cfg: ContentIsolationConfig = toml::from_str("").unwrap();
599        assert!(cfg.mcp_to_acp_boundary);
600    }
601
602    // ── PiiFilterConfig / SecretMaskingConfig safe-default posture (#6263) ──────────────
603
604    #[test]
605    fn pii_filter_default_is_enabled() {
606        assert!(PiiFilterConfig::default().enabled);
607    }
608
609    #[test]
610    fn pii_filter_deserialize_absent_defaults_enabled_true() {
611        // The whole [security.pii_filter] table is absent — falls back to struct Default.
612        let cfg: PiiFilterConfig = toml::from_str("").unwrap();
613        assert!(cfg.enabled);
614    }
615
616    #[test]
617    fn pii_filter_deserialize_section_present_without_enabled_key_defaults_true() {
618        // The section exists (e.g. only `filter_email` was set) but omits `enabled` — must
619        // resolve via the field-level `default_true`, not `bool::default()`.
620        let cfg: PiiFilterConfig = toml::from_str("filter_email = false").unwrap();
621        assert!(cfg.enabled);
622        assert!(!cfg.filter_email);
623    }
624
625    #[test]
626    fn pii_filter_deserialize_explicit_false_is_respected() {
627        let cfg: PiiFilterConfig = toml::from_str("enabled = false").unwrap();
628        assert!(!cfg.enabled);
629    }
630
631    #[test]
632    fn secret_masking_default_is_enabled() {
633        assert!(SecretMaskingConfig::default().enabled);
634    }
635
636    #[test]
637    fn secret_masking_deserialize_absent_defaults_enabled_true() {
638        let cfg: SecretMaskingConfig = toml::from_str("").unwrap();
639        assert!(cfg.enabled);
640    }
641
642    #[test]
643    fn secret_masking_deserialize_section_present_without_enabled_key_defaults_true() {
644        let cfg: SecretMaskingConfig = toml::from_str("min_secret_len = 12").unwrap();
645        assert!(cfg.enabled);
646        assert_eq!(cfg.min_secret_len, 12);
647    }
648
649    #[test]
650    fn secret_masking_deserialize_explicit_false_is_respected() {
651        let cfg: SecretMaskingConfig = toml::from_str("enabled = false").unwrap();
652        assert!(!cfg.enabled);
653    }
654
655    fn de_guard(toml: &str) -> Result<EmbeddingGuardConfig, toml::de::Error> {
656        toml::from_str(toml)
657    }
658
659    #[test]
660    fn threshold_valid() {
661        let cfg = de_guard("threshold = 0.35\nmin_samples = 5").unwrap();
662        assert!((cfg.threshold - 0.35).abs() < f64::EPSILON);
663    }
664
665    #[test]
666    fn threshold_one_valid() {
667        let cfg = de_guard("threshold = 1.0\nmin_samples = 1").unwrap();
668        assert!((cfg.threshold - 1.0).abs() < f64::EPSILON);
669    }
670
671    #[test]
672    fn threshold_zero_rejected() {
673        assert!(de_guard("threshold = 0.0\nmin_samples = 1").is_err());
674    }
675
676    #[test]
677    fn threshold_above_one_rejected() {
678        assert!(de_guard("threshold = 1.5\nmin_samples = 1").is_err());
679    }
680
681    #[test]
682    fn threshold_negative_rejected() {
683        assert!(de_guard("threshold = -0.1\nmin_samples = 1").is_err());
684    }
685
686    #[test]
687    fn min_samples_zero_rejected() {
688        assert!(de_guard("threshold = 0.35\nmin_samples = 0").is_err());
689    }
690
691    #[test]
692    fn min_samples_one_valid() {
693        let cfg = de_guard("threshold = 0.35\nmin_samples = 1").unwrap();
694        assert_eq!(cfg.min_samples, 1);
695    }
696}
697
698// ---------------------------------------------------------------------------
699// CausalIpiConfig
700// ---------------------------------------------------------------------------
701
702fn default_causal_threshold() -> f32 {
703    0.7
704}
705
706fn default_probe_max_tokens() -> u32 {
707    100
708}
709
710fn default_probe_timeout_ms() -> u64 {
711    3000
712}
713
714/// Temporal causal IPI analysis at tool-return boundaries.
715///
716/// When enabled, the agent generates behavioral probes before and after tool batch dispatch
717/// and compares them to detect behavioral deviation caused by injected instructions in
718/// tool outputs. Probes are per-batch (2 LLM calls total), not per individual tool.
719///
720/// Config section: `[security.causal_ipi]`
721#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
722pub struct CausalIpiConfig {
723    /// Master switch. Default: false (opt-in).
724    #[serde(default)]
725    pub enabled: bool,
726
727    /// Causal attribution score threshold for flagging. Range: (0.0, 1.0]. Default 0.7.
728    ///
729    /// Scores above this value trigger a WARN log, metric increment, and `SecurityEvent`.
730    /// Content is never blocked — this is an observation layer only.
731    #[serde(
732        default = "default_causal_threshold",
733        deserialize_with = "crate::de_helpers::de_unit_open"
734    )]
735    pub threshold: f32,
736
737    /// LLM provider name from `[[llm.providers]]` for probe calls.
738    ///
739    /// Should reference a fast/cheap provider — probes run on every tool batch return.
740    /// When `None`, falls back to the agent's default provider.
741    #[serde(default)]
742    pub provider: Option<String>,
743
744    /// Maximum tokens for each probe response. Limits cost per probe call. Default: 100.
745    ///
746    /// Two probes per batch = max `2 * probe_max_tokens` output tokens per tool batch.
747    #[serde(default = "default_probe_max_tokens")]
748    pub probe_max_tokens: u32,
749
750    /// Timeout in milliseconds for each individual probe LLM call. Default: 3000.
751    ///
752    /// On timeout: WARN log, skip causal analysis for the batch (never block).
753    #[serde(default = "default_probe_timeout_ms")]
754    pub probe_timeout_ms: u64,
755
756    /// Shadow memory configuration for cross-turn trajectory analysis.
757    #[serde(default)]
758    pub shadow_memory: ShadowMemoryConfig,
759}
760
761impl Default for CausalIpiConfig {
762    fn default() -> Self {
763        Self {
764            enabled: false,
765            threshold: default_causal_threshold(),
766            provider: None,
767            probe_max_tokens: default_probe_max_tokens(),
768            probe_timeout_ms: default_probe_timeout_ms(),
769            shadow_memory: ShadowMemoryConfig::default(),
770        }
771    }
772}
773
774// ---------------------------------------------------------------------------
775// ShadowMemoryConfig
776// ---------------------------------------------------------------------------
777
778fn default_shadow_window() -> usize {
779    8
780}
781
782fn default_shadow_max_events() -> usize {
783    64
784}
785
786fn default_shadow_drift_threshold() -> f32 {
787    0.6
788}
789
790fn validate_shadow_window<'de, D>(deserializer: D) -> Result<usize, D::Error>
791where
792    D: serde::Deserializer<'de>,
793{
794    let value = <usize as serde::Deserialize>::deserialize(deserializer)?;
795    if value == 0 {
796        return Err(serde::de::Error::custom(
797            "shadow_memory.window_size must be >= 1",
798        ));
799    }
800    Ok(value)
801}
802
803fn validate_shadow_max_events<'de, D>(deserializer: D) -> Result<usize, D::Error>
804where
805    D: serde::Deserializer<'de>,
806{
807    let value = <usize as serde::Deserialize>::deserialize(deserializer)?;
808    if value == 0 {
809        return Err(serde::de::Error::custom(
810            "shadow_memory.max_events must be >= 1",
811        ));
812    }
813    Ok(value)
814}
815
816/// Per-session append-only event store for cross-turn trajectory analysis.
817///
818/// Detects multi-turn attacks that distribute payload across several turns —
819/// invisible to the stateless [`CausalIpiConfig`] single-batch analysis.
820///
821/// Config section: `[security.causal_ipi.shadow_memory]`
822///
823/// # Examples
824///
825/// ```toml
826/// [security.causal_ipi.shadow_memory]
827/// enabled = true
828/// window_size = 8
829/// max_events = 64
830/// drift_threshold = 0.6
831/// ```
832#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
833pub struct ShadowMemoryConfig {
834    /// Enable shadow memory trajectory tracking. Default: false.
835    #[serde(default)]
836    pub enabled: bool,
837
838    /// Sliding window size for drift computation. Must be >= 1. Default: 8.
839    #[serde(
840        default = "default_shadow_window",
841        deserialize_with = "validate_shadow_window"
842    )]
843    pub window_size: usize,
844
845    /// Maximum events retained before oldest are evicted. Must be >= 1. Default: 64.
846    #[serde(
847        default = "default_shadow_max_events",
848        deserialize_with = "validate_shadow_max_events"
849    )]
850    pub max_events: usize,
851
852    /// Goal drift score threshold for flagging. Range: (0.0, 1.0]. Default: 0.6.
853    #[serde(
854        default = "default_shadow_drift_threshold",
855        deserialize_with = "crate::de_helpers::de_unit_open"
856    )]
857    pub drift_threshold: f32,
858}
859
860impl Default for ShadowMemoryConfig {
861    fn default() -> Self {
862        Self {
863            enabled: false,
864            window_size: default_shadow_window(),
865            max_events: default_shadow_max_events(),
866            drift_threshold: default_shadow_drift_threshold(),
867        }
868    }
869}
870
871#[cfg(test)]
872mod causal_ipi_tests {
873    use super::*;
874
875    #[test]
876    fn causal_ipi_defaults() {
877        let cfg = CausalIpiConfig::default();
878        assert!(!cfg.enabled);
879        assert!((cfg.threshold - 0.7).abs() < 1e-6);
880        assert!(cfg.provider.is_none());
881        assert_eq!(cfg.probe_max_tokens, 100);
882        assert_eq!(cfg.probe_timeout_ms, 3000);
883    }
884
885    #[test]
886    fn causal_ipi_deserialize_enabled() {
887        let toml = r#"
888            enabled = true
889            threshold = 0.8
890            provider = "fast"
891            probe_max_tokens = 150
892            probe_timeout_ms = 5000
893        "#;
894        let cfg: CausalIpiConfig = toml::from_str(toml).unwrap();
895        assert!(cfg.enabled);
896        assert!((cfg.threshold - 0.8).abs() < 1e-6);
897        assert_eq!(cfg.provider.as_deref(), Some("fast"));
898        assert_eq!(cfg.probe_max_tokens, 150);
899        assert_eq!(cfg.probe_timeout_ms, 5000);
900    }
901
902    #[test]
903    fn causal_ipi_threshold_zero_rejected() {
904        let result: Result<CausalIpiConfig, _> = toml::from_str("threshold = 0.0");
905        assert!(result.is_err());
906    }
907
908    #[test]
909    fn causal_ipi_threshold_above_one_rejected() {
910        let result: Result<CausalIpiConfig, _> = toml::from_str("threshold = 1.1");
911        assert!(result.is_err());
912    }
913
914    #[test]
915    fn causal_ipi_threshold_exactly_one_accepted() {
916        let cfg: CausalIpiConfig = toml::from_str("threshold = 1.0").unwrap();
917        assert!((cfg.threshold - 1.0).abs() < 1e-6);
918    }
919}
920
921#[cfg(test)]
922mod shadow_memory_config_tests {
923    use super::*;
924
925    #[test]
926    fn shadow_memory_defaults() {
927        let cfg = ShadowMemoryConfig::default();
928        assert!(!cfg.enabled);
929        assert_eq!(cfg.window_size, 8);
930        assert_eq!(cfg.max_events, 64);
931        assert!((cfg.drift_threshold - 0.6).abs() < 1e-6);
932    }
933
934    #[test]
935    fn shadow_memory_window_zero_rejected() {
936        let result: Result<ShadowMemoryConfig, _> = toml::from_str("window_size = 0");
937        assert!(result.is_err());
938    }
939
940    #[test]
941    fn shadow_memory_max_events_zero_rejected() {
942        let result: Result<ShadowMemoryConfig, _> = toml::from_str("max_events = 0");
943        assert!(result.is_err());
944    }
945
946    #[test]
947    fn shadow_memory_drift_threshold_zero_rejected() {
948        let result: Result<ShadowMemoryConfig, _> = toml::from_str("drift_threshold = 0.0");
949        assert!(result.is_err());
950    }
951
952    #[test]
953    fn shadow_memory_drift_threshold_above_one_rejected() {
954        let result: Result<ShadowMemoryConfig, _> = toml::from_str("drift_threshold = 1.1");
955        assert!(result.is_err());
956    }
957
958    #[test]
959    fn shadow_memory_drift_threshold_exactly_one_accepted() {
960        let cfg: ShadowMemoryConfig = toml::from_str("drift_threshold = 1.0").unwrap();
961        assert!((cfg.drift_threshold - 1.0).abs() < 1e-6);
962    }
963
964    #[test]
965    fn shadow_memory_full_deserialization() {
966        let toml = r"
967            enabled = true
968            window_size = 4
969            max_events = 32
970            drift_threshold = 0.8
971        ";
972        let cfg: ShadowMemoryConfig = toml::from_str(toml).unwrap();
973        assert!(cfg.enabled);
974        assert_eq!(cfg.window_size, 4);
975        assert_eq!(cfg.max_events, 32);
976        assert!((cfg.drift_threshold - 0.8).abs() < 1e-6);
977    }
978}