Skip to main content

zeph_config/
security.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use std::collections::HashMap;
5
6use serde::{Deserialize, Serialize};
7use zeph_common::SkillTrustLevel;
8
9use crate::tools::{AutonomyLevel, PreExecutionVerifierConfig};
10
11use crate::defaults::default_true;
12use crate::vigil::VigilConfig;
13
14/// Fine-grained controls for the skill body scanner.
15///
16/// Nested under `[skills.trust.scanner]` in TOML.
17#[derive(Debug, Clone, Deserialize, Serialize)]
18pub struct ScannerConfig {
19    /// Scan skill body content for injection patterns at load time.
20    ///
21    /// More specific than `scan_on_load` (which controls whether `scan_loaded()` is called at
22    /// all). When `scan_on_load = true` and `injection_patterns = false`, the scan loop still
23    /// runs but skips the injection pattern check.
24    #[serde(default = "default_true")]
25    pub injection_patterns: bool,
26    /// Check whether a skill's `allowed_tools` exceed its trust level's permissions.
27    ///
28    /// When enabled, the bootstrap calls `check_escalations()` on the registry and logs
29    /// warnings for any tool declarations that violate the trust boundary.
30    #[serde(default)]
31    pub capability_escalation_check: bool,
32}
33
34impl Default for ScannerConfig {
35    fn default() -> Self {
36        Self {
37            injection_patterns: true,
38            capability_escalation_check: false,
39        }
40    }
41}
42use crate::rate_limit::RateLimitConfig;
43use crate::sanitizer::GuardrailConfig;
44use crate::sanitizer::{
45    CausalIpiConfig, ContentIsolationConfig, ExfiltrationGuardConfig, MemoryWriteValidationConfig,
46    PiiFilterConfig, ResponseVerificationConfig,
47};
48
49fn default_trust_default_level() -> SkillTrustLevel {
50    SkillTrustLevel::Quarantined
51}
52
53fn default_trust_local_level() -> SkillTrustLevel {
54    SkillTrustLevel::Trusted
55}
56
57fn default_trust_hash_mismatch_level() -> SkillTrustLevel {
58    SkillTrustLevel::Quarantined
59}
60
61fn default_trust_bundled_level() -> SkillTrustLevel {
62    SkillTrustLevel::Trusted
63}
64
65fn default_llm_timeout() -> u64 {
66    120
67}
68
69fn default_embedding_timeout() -> u64 {
70    30
71}
72
73fn default_a2a_timeout() -> u64 {
74    30
75}
76
77fn default_max_parallel_tools() -> usize {
78    8
79}
80
81fn default_llm_request_timeout() -> u64 {
82    600
83}
84
85fn default_context_prep_timeout() -> u64 {
86    30
87}
88
89fn default_no_providers_backoff_secs() -> u64 {
90    2
91}
92
93/// Skill trust policy configuration, nested under `[skills.trust]` in TOML.
94///
95/// Controls how trust levels are assigned to skills at load time based on their
96/// origin (local filesystem vs network) and integrity (hash verification result).
97///
98/// # Example (TOML)
99///
100/// ```toml
101/// [skills.trust]
102/// default_level = "quarantined"
103/// local_level = "trusted"
104/// scan_on_load = true
105/// ```
106#[derive(Debug, Clone, Deserialize, Serialize)]
107pub struct TrustConfig {
108    /// Trust level assigned to skills from unknown or remote origins. Default: `quarantined`.
109    #[serde(default = "default_trust_default_level")]
110    pub default_level: SkillTrustLevel,
111    /// Trust level assigned to skills found on the local filesystem. Default: `trusted`.
112    #[serde(default = "default_trust_local_level")]
113    pub local_level: SkillTrustLevel,
114    /// Trust level assigned when a skill's content hash does not match the stored hash.
115    /// Default: `quarantined`.
116    #[serde(default = "default_trust_hash_mismatch_level")]
117    pub hash_mismatch_level: SkillTrustLevel,
118    /// Trust level assigned to bundled (built-in) skills shipped with the binary. Default: `trusted`.
119    #[serde(default = "default_trust_bundled_level")]
120    pub bundled_level: SkillTrustLevel,
121    /// Scan skill body content for injection patterns at load time.
122    ///
123    /// When `true`, `SkillRegistry::scan_loaded()` is called at agent startup.
124    /// This is **advisory only** — scan results are logged as warnings and do not
125    /// automatically change trust levels or block tool calls.
126    ///
127    /// Defaults to `true` (secure by default).
128    #[serde(default = "default_true")]
129    pub scan_on_load: bool,
130    /// Fine-grained scanner controls (injection patterns, capability escalation).
131    #[serde(default)]
132    pub scanner: ScannerConfig,
133}
134
135impl Default for TrustConfig {
136    fn default() -> Self {
137        Self {
138            default_level: default_trust_default_level(),
139            local_level: default_trust_local_level(),
140            hash_mismatch_level: default_trust_hash_mismatch_level(),
141            bundled_level: default_trust_bundled_level(),
142            scan_on_load: true,
143            scanner: ScannerConfig::default(),
144        }
145    }
146}
147
148// ── Trajectory Sentinel ──────────────────────────────────────────────────────
149
150fn default_decay_per_turn() -> f32 {
151    0.85
152}
153fn default_window_turns() -> u32 {
154    8
155}
156fn default_elevated_at() -> f32 {
157    2.0
158}
159fn default_high_at() -> f32 {
160    4.0
161}
162fn default_critical_at() -> f32 {
163    8.0
164}
165fn default_alert_threshold() -> f32 {
166    4.0
167}
168fn default_auto_recover_after_turns() -> u32 {
169    16
170}
171fn default_subagent_inheritance_factor() -> f32 {
172    0.5
173}
174fn default_high_call_rate_threshold() -> u32 {
175    12
176}
177fn default_unusual_read_threshold() -> u32 {
178    24
179}
180fn default_auto_recover_floor() -> u32 {
181    4
182}
183
184/// Configuration for `TrajectorySentinel`, nested under `[security.trajectory]` in TOML.
185///
186/// Controls signal decay, risk level thresholds, auto-recovery, and subagent inheritance.
187///
188/// # Example (TOML)
189///
190/// ```toml
191/// [security.trajectory]
192/// decay_per_turn = 0.85
193/// elevated_at = 2.0
194/// high_at = 4.0
195/// critical_at = 8.0
196/// alert_threshold = 4.0
197/// auto_recover_after_turns = 16
198/// subagent_inheritance_factor = 0.5
199/// ```
200#[derive(Debug, Clone, Deserialize, Serialize)]
201pub struct TrajectorySentinelConfig {
202    /// Multiplicative decay applied to the running score at each `advance_turn()` call.
203    ///
204    /// Must be in `(0.0, 1.0]`. Default 0.85 gives a half-life of ≈ 4.3 turns.
205    #[serde(default = "default_decay_per_turn")]
206    pub decay_per_turn: f32,
207    /// Number of past turns to keep in the signal buffer.
208    ///
209    /// Older signals are evicted once the buffer exceeds this size. Default 8.
210    #[serde(default = "default_window_turns")]
211    pub window_turns: u32,
212    /// Score threshold for transitioning from `Calm` to `Elevated`. Default 2.0.
213    #[serde(default = "default_elevated_at")]
214    pub elevated_at: f32,
215    /// Score threshold for transitioning from `Elevated` to `High`. Default 4.0.
216    #[serde(default = "default_high_at")]
217    pub high_at: f32,
218    /// Score threshold for transitioning from `High` to `Critical`. Default 8.0.
219    #[serde(default = "default_critical_at")]
220    pub critical_at: f32,
221    /// Score at which `PolicyGateExecutor` is notified via `RiskAlert`. Default 4.0.
222    ///
223    /// Decoupled from `elevated_at` to prevent alert noise for routine minor events.
224    #[serde(default = "default_alert_threshold")]
225    pub alert_threshold: f32,
226    /// Consecutive `Critical` turns before a hard auto-recover reset. Minimum 4. Default 16.
227    #[serde(default = "default_auto_recover_after_turns")]
228    pub auto_recover_after_turns: u32,
229    /// Fraction of parent score inherited by a subagent when parent is `>= Elevated`.
230    ///
231    /// Default 0.5 (≈ one decay half-life). Config validator warns when this deviates
232    /// more than 0.1 from `decay_per_turn ^ (ln(0.5) / ln(decay_per_turn))`.
233    #[serde(default = "default_subagent_inheritance_factor")]
234    pub subagent_inheritance_factor: f32,
235    /// Tool-call count per 3-turn window above which `HighCallRate` fires. Default 12.
236    #[serde(default = "default_high_call_rate_threshold")]
237    pub high_call_rate_threshold: u32,
238    /// Distinct paths read within `window_turns` above which `UnusualReadVolume` fires. Default 24.
239    #[serde(default = "default_unusual_read_threshold")]
240    pub unusual_read_threshold: u32,
241}
242
243impl Default for TrajectorySentinelConfig {
244    fn default() -> Self {
245        Self {
246            decay_per_turn: default_decay_per_turn(),
247            window_turns: default_window_turns(),
248            elevated_at: default_elevated_at(),
249            high_at: default_high_at(),
250            critical_at: default_critical_at(),
251            alert_threshold: default_alert_threshold(),
252            auto_recover_after_turns: default_auto_recover_after_turns(),
253            subagent_inheritance_factor: default_subagent_inheritance_factor(),
254            high_call_rate_threshold: default_high_call_rate_threshold(),
255            unusual_read_threshold: default_unusual_read_threshold(),
256        }
257    }
258}
259
260impl TrajectorySentinelConfig {
261    /// Validate numeric bounds. Returns an error string when validation fails.
262    ///
263    /// # Errors
264    ///
265    /// Returns a description of the first validation failure found.
266    pub fn validate(&self) -> Result<(), String> {
267        if self.decay_per_turn <= 0.0 || self.decay_per_turn > 1.0 {
268            return Err(format!(
269                "trajectory.decay_per_turn must be in (0.0, 1.0]; got {}",
270                self.decay_per_turn
271            ));
272        }
273        if self.elevated_at >= self.high_at {
274            return Err(format!(
275                "trajectory: elevated_at ({}) must be < high_at ({})",
276                self.elevated_at, self.high_at
277            ));
278        }
279        if self.high_at >= self.critical_at {
280            return Err(format!(
281                "trajectory: high_at ({}) must be < critical_at ({})",
282                self.high_at, self.critical_at
283            ));
284        }
285        if self.auto_recover_after_turns < default_auto_recover_floor() {
286            return Err(format!(
287                "trajectory.auto_recover_after_turns must be >= {}; got {}",
288                default_auto_recover_floor(),
289                self.auto_recover_after_turns
290            ));
291        }
292        // Advisory: warn when subagent_inheritance_factor deviates from calibrated value.
293        if self.decay_per_turn < 1.0 {
294            let ideal = self
295                .decay_per_turn
296                .powf(0.5_f32.ln() / self.decay_per_turn.ln());
297            if (self.subagent_inheritance_factor - ideal).abs() > 0.1 {
298                // Not a hard error — warn only.
299                tracing::warn!(
300                    configured = self.subagent_inheritance_factor,
301                    ideal = ideal,
302                    decay = self.decay_per_turn,
303                    "trajectory.subagent_inheritance_factor deviates from calibrated value by more than 0.1"
304                );
305            }
306        }
307        Ok(())
308    }
309}
310
311// ── Capability Scopes ────────────────────────────────────────────────────────
312
313/// Strictness mode for glob pattern matching against the tool registry.
314///
315/// Controls whether a zero-match glob is a fatal error or a warning.
316#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, Default)]
317#[serde(rename_all = "snake_case")]
318pub enum PatternStrictness {
319    /// All namespaces are strict — zero-match globs are fatal.
320    Strict,
321    /// All namespaces are permissive — zero-match globs are warnings only.
322    Permissive,
323    /// `builtin:` and `skill:` globs are strict; `mcp:`, `acp:`, `a2a:` are provisional.
324    ///
325    /// This is the default because MCP servers may not be connected at startup.
326    #[default]
327    ProvisionalForDynamicNamespaces,
328}
329
330/// Configuration for a single task-type scope, nested under
331/// `[security.capability_scopes.<task_type>]`.
332///
333/// # Example (TOML)
334///
335/// ```toml
336/// [security.capability_scopes.research]
337/// patterns = ["builtin:fetch", "builtin:web_scrape", "builtin:search_*"]
338/// ```
339#[derive(Debug, Clone, Deserialize, Serialize)]
340pub struct ScopeConfig {
341    /// Glob patterns over fully-qualified tool ids (`<namespace>:<tool>`).
342    ///
343    /// Evaluated against the materialised tool registry at agent build time.
344    #[serde(default)]
345    pub patterns: Vec<String>,
346}
347
348/// Top-level capability scopes configuration, nested under `[security.capability_scopes]`.
349///
350/// # Example (TOML)
351///
352/// ```toml
353/// [security.capability_scopes]
354/// default_scope = "general"
355/// strict = true
356///
357/// [security.capability_scopes.general]
358/// patterns = ["*"]
359///
360/// [security.capability_scopes.research]
361/// patterns = ["builtin:fetch", "builtin:web_scrape", "builtin:search_*", "builtin:read"]
362///
363/// [security.capability_scopes.code_edit]
364/// patterns = ["builtin:read", "builtin:edit", "builtin:write", "builtin:shell", "builtin:glob"]
365/// ```
366#[derive(Debug, Clone, Deserialize, Serialize, Default)]
367pub struct CapabilityScopesConfig {
368    /// Name of the scope used when no task type is specified. Default: `"general"`.
369    ///
370    /// When `default_scope = "general"` and a `[security.capability_scopes.general]` section
371    /// with `patterns = ["*"]` exists, scoping is a no-op identity (full tool set surfaced).
372    #[serde(default = "default_scope_name")]
373    pub default_scope: String,
374    /// When `true`, an unrecognised `task_type` is a fatal startup error.
375    /// When `false`, falls back to `default_scope`. Default: `false`.
376    #[serde(default)]
377    pub strict: bool,
378    /// Per-namespace strictness for zero-match glob patterns.
379    #[serde(default)]
380    pub pattern_strictness: PatternStrictness,
381    /// Named scopes. Keys are task-type names; values are their scope configurations.
382    #[serde(default, flatten)]
383    pub scopes: HashMap<String, ScopeConfig>,
384}
385
386fn default_scope_name() -> String {
387    "general".to_owned()
388}
389
390// ── Agent security configuration ─────────────────────────────────────────────
391
392/// Agent security configuration, nested under `[security]` in TOML.
393///
394/// Aggregates all security-related subsystems: content isolation, exfiltration guards,
395/// memory write validation, PII filtering, rate limiting, prompt injection screening,
396/// and response verification.
397///
398/// # Example (TOML)
399///
400/// ```toml
401/// [security]
402/// redact_secrets = true
403/// autonomy_level = "moderate"
404///
405/// [security.rate_limit]
406/// enabled = true
407/// shell_calls_per_minute = 20
408/// ```
409#[derive(Debug, Clone, Deserialize, Serialize)]
410pub struct SecurityConfig {
411    /// Automatically redact detected secrets from tool outputs before they reach the LLM.
412    /// Default: `true`.
413    #[serde(default = "default_true")]
414    pub redact_secrets: bool,
415    /// Autonomy level controlling which tool actions require explicit user confirmation.
416    #[serde(default)]
417    pub autonomy_level: AutonomyLevel,
418    #[serde(default)]
419    pub content_isolation: ContentIsolationConfig,
420    #[serde(default)]
421    pub exfiltration_guard: ExfiltrationGuardConfig,
422    /// Memory write validation (enabled by default).
423    #[serde(default)]
424    pub memory_validation: MemoryWriteValidationConfig,
425    /// PII filter for tool outputs and debug dumps (opt-in, disabled by default).
426    #[serde(default)]
427    pub pii_filter: PiiFilterConfig,
428    /// Tool action rate limiter (opt-in, disabled by default).
429    #[serde(default)]
430    pub rate_limit: RateLimitConfig,
431    /// Pre-execution verifiers (enabled by default).
432    #[serde(default)]
433    pub pre_execution_verify: PreExecutionVerifierConfig,
434    /// LLM-based prompt injection pre-screener (opt-in, disabled by default).
435    #[serde(default)]
436    pub guardrail: GuardrailConfig,
437    /// Post-LLM response verification layer (enabled by default).
438    #[serde(default)]
439    pub response_verification: ResponseVerificationConfig,
440    /// Temporal causal IPI analysis at tool-return boundaries (opt-in, disabled by default).
441    #[serde(default)]
442    pub causal_ipi: CausalIpiConfig,
443    /// VIGIL verify-before-commit intent anchoring gate (enabled by default).
444    ///
445    /// Runs a regex tripwire before `sanitize_tool_output` to intercept low-effort injection
446    /// patterns. See `[[security.vigil]]` in TOML and spec `010-6-vigil-intent-anchoring`.
447    #[serde(default)]
448    pub vigil: VigilConfig,
449    /// Trajectory risk sentinel configuration.
450    ///
451    /// Controls signal decay, risk level thresholds, auto-recovery, and subagent inheritance.
452    /// See spec 050 and `crates/zeph-core/src/agent/trajectory.rs`.
453    #[serde(default)]
454    pub trajectory: TrajectorySentinelConfig,
455    /// Capability scope configuration.
456    ///
457    /// Maps task-type names to glob-pattern allow-lists over fully-qualified tool ids.
458    /// When empty, scoping is a no-op (full tool set surfaced to LLM).
459    #[serde(default)]
460    pub capability_scopes: CapabilityScopesConfig,
461}
462
463impl Default for SecurityConfig {
464    fn default() -> Self {
465        Self {
466            redact_secrets: true,
467            autonomy_level: AutonomyLevel::default(),
468            content_isolation: ContentIsolationConfig::default(),
469            exfiltration_guard: ExfiltrationGuardConfig::default(),
470            memory_validation: MemoryWriteValidationConfig::default(),
471            pii_filter: PiiFilterConfig::default(),
472            rate_limit: RateLimitConfig::default(),
473            pre_execution_verify: PreExecutionVerifierConfig::default(),
474            guardrail: GuardrailConfig::default(),
475            response_verification: ResponseVerificationConfig::default(),
476            causal_ipi: CausalIpiConfig::default(),
477            vigil: VigilConfig::default(),
478            trajectory: TrajectorySentinelConfig::default(),
479            capability_scopes: CapabilityScopesConfig::default(),
480        }
481    }
482}
483
484/// Timeout configuration for external operations, nested under `[timeouts]` in TOML.
485///
486/// All timeouts are in seconds. Exceeding a timeout returns an error to the agent
487/// loop rather than blocking indefinitely.
488///
489/// # Example (TOML)
490///
491/// ```toml
492/// [timeouts]
493/// llm_seconds = 60
494/// embedding_seconds = 15
495/// max_parallel_tools = 4
496/// ```
497#[derive(Debug, Clone, Copy, Deserialize, Serialize)]
498pub struct TimeoutConfig {
499    /// Timeout for streaming LLM first-token responses, in seconds. Default: `120`.
500    #[serde(default = "default_llm_timeout")]
501    pub llm_seconds: u64,
502    /// Total wall-clock timeout for a complete LLM request (all tokens), in seconds.
503    /// Default: `600`.
504    #[serde(default = "default_llm_request_timeout")]
505    pub llm_request_timeout_secs: u64,
506    /// Timeout for embedding API calls, in seconds. Default: `30`.
507    #[serde(default = "default_embedding_timeout")]
508    pub embedding_seconds: u64,
509    /// Timeout for A2A agent-to-agent calls, in seconds. Default: `30`.
510    #[serde(default = "default_a2a_timeout")]
511    pub a2a_seconds: u64,
512    /// Maximum number of tool calls that may execute concurrently in a single turn.
513    /// Default: `8`.
514    #[serde(default = "default_max_parallel_tools")]
515    pub max_parallel_tools: usize,
516    /// Maximum wall-clock time (seconds) allowed for `advance_context_lifecycle` (memory recall,
517    /// graph retrieval, proactive compression, context assembly) before it is aborted and the
518    /// agent proceeds with a degraded (cached) context.
519    ///
520    /// Setting this too low may skip useful memory recall; setting it too high blocks the agent
521    /// when embed providers are rate-limited or unavailable. Default: `30`.
522    #[serde(default = "default_context_prep_timeout")]
523    pub context_prep_timeout_secs: u64,
524    /// How long to wait (seconds) before retrying a turn after the previous turn ended with
525    /// `no providers available`. Prevents a busy-wait loop when all LLM backends are down.
526    /// Default: `2`.
527    #[serde(default = "default_no_providers_backoff_secs")]
528    pub no_providers_backoff_secs: u64,
529}
530
531impl Default for TimeoutConfig {
532    fn default() -> Self {
533        Self {
534            llm_seconds: default_llm_timeout(),
535            llm_request_timeout_secs: default_llm_request_timeout(),
536            embedding_seconds: default_embedding_timeout(),
537            a2a_seconds: default_a2a_timeout(),
538            max_parallel_tools: default_max_parallel_tools(),
539            context_prep_timeout_secs: default_context_prep_timeout(),
540            no_providers_backoff_secs: default_no_providers_backoff_secs(),
541        }
542    }
543}
544
545#[cfg(test)]
546mod tests {
547    use super::*;
548
549    #[test]
550    fn trust_config_default_has_scan_on_load_true() {
551        let config = TrustConfig::default();
552        assert!(config.scan_on_load);
553    }
554
555    #[test]
556    fn trust_config_serde_roundtrip_with_scan_on_load() {
557        let config = TrustConfig {
558            default_level: SkillTrustLevel::Quarantined,
559            local_level: SkillTrustLevel::Trusted,
560            hash_mismatch_level: SkillTrustLevel::Quarantined,
561            bundled_level: SkillTrustLevel::Trusted,
562            scan_on_load: false,
563            scanner: ScannerConfig::default(),
564        };
565        let toml = toml::to_string(&config).expect("serialize");
566        let deserialized: TrustConfig = toml::from_str(&toml).expect("deserialize");
567        assert!(!deserialized.scan_on_load);
568        assert_eq!(deserialized.bundled_level, SkillTrustLevel::Trusted);
569    }
570
571    #[test]
572    fn trust_config_missing_scan_on_load_defaults_to_true() {
573        let toml = r#"
574default_level = "quarantined"
575local_level = "trusted"
576hash_mismatch_level = "quarantined"
577"#;
578        let config: TrustConfig = toml::from_str(toml).expect("deserialize");
579        assert!(
580            config.scan_on_load,
581            "missing scan_on_load must default to true"
582        );
583    }
584
585    #[test]
586    fn trust_config_default_has_bundled_level_trusted() {
587        let config = TrustConfig::default();
588        assert_eq!(config.bundled_level, SkillTrustLevel::Trusted);
589    }
590
591    #[test]
592    fn trust_config_missing_bundled_level_defaults_to_trusted() {
593        let toml = r#"
594default_level = "quarantined"
595local_level = "trusted"
596hash_mismatch_level = "quarantined"
597"#;
598        let config: TrustConfig = toml::from_str(toml).expect("deserialize");
599        assert_eq!(
600            config.bundled_level,
601            SkillTrustLevel::Trusted,
602            "missing bundled_level must default to trusted"
603        );
604    }
605
606    #[test]
607    fn scanner_config_defaults() {
608        let cfg = ScannerConfig::default();
609        assert!(cfg.injection_patterns);
610        assert!(!cfg.capability_escalation_check);
611    }
612
613    #[test]
614    fn scanner_config_serde_roundtrip() {
615        let cfg = ScannerConfig {
616            injection_patterns: false,
617            capability_escalation_check: true,
618        };
619        let toml = toml::to_string(&cfg).expect("serialize");
620        let back: ScannerConfig = toml::from_str(&toml).expect("deserialize");
621        assert!(!back.injection_patterns);
622        assert!(back.capability_escalation_check);
623    }
624
625    #[test]
626    fn trust_config_scanner_defaults_when_missing() {
627        let toml = r#"
628default_level = "quarantined"
629local_level = "trusted"
630hash_mismatch_level = "quarantined"
631"#;
632        let config: TrustConfig = toml::from_str(toml).expect("deserialize");
633        assert!(config.scanner.injection_patterns);
634        assert!(!config.scanner.capability_escalation_check);
635    }
636
637    // ------------------------------------------------------------------
638    // TimeoutConfig — new fields added in #3357
639    // ------------------------------------------------------------------
640
641    #[test]
642    fn timeout_config_context_prep_timeout_default() {
643        let cfg = TimeoutConfig::default();
644        assert_eq!(
645            cfg.context_prep_timeout_secs, 30,
646            "context_prep_timeout_secs default must be 30s (#3357)"
647        );
648    }
649
650    #[test]
651    fn timeout_config_no_providers_backoff_default() {
652        let cfg = TimeoutConfig::default();
653        assert_eq!(
654            cfg.no_providers_backoff_secs, 2,
655            "no_providers_backoff_secs default must be 2s (#3357)"
656        );
657    }
658
659    #[test]
660    fn timeout_config_new_fields_deserialize_from_toml() {
661        let toml = r"
662context_prep_timeout_secs = 60
663no_providers_backoff_secs = 10
664";
665        let cfg: TimeoutConfig = toml::from_str(toml).expect("deserialize");
666        assert_eq!(cfg.context_prep_timeout_secs, 60);
667        assert_eq!(cfg.no_providers_backoff_secs, 10);
668    }
669
670    #[test]
671    fn timeout_config_new_fields_default_when_missing_from_toml() {
672        // An empty TOML section must produce the same values as TimeoutConfig::default().
673        let cfg: TimeoutConfig = toml::from_str("").expect("deserialize empty");
674        assert_eq!(cfg.context_prep_timeout_secs, 30);
675        assert_eq!(cfg.no_providers_backoff_secs, 2);
676    }
677}