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