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