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// ── ShadowSentinel ──────────────────────────────────────────────────────────
312
313fn default_shadow_max_context_events() -> usize {
314    50
315}
316fn default_shadow_probe_timeout_ms() -> u64 {
317    2000
318}
319fn default_shadow_max_probes_per_turn() -> usize {
320    3
321}
322fn default_shadow_probe_patterns() -> Vec<String> {
323    vec![
324        "builtin:shell".to_owned(),
325        "builtin:write".to_owned(),
326        "builtin:edit".to_owned(),
327        "mcp:*/file_*".to_owned(),
328        "mcp:*/exec_*".to_owned(),
329    ]
330}
331
332/// Configuration for the `ShadowSentinel` subsystem, nested under `[security.shadow_sentinel]`.
333///
334/// `ShadowSentinel` is a defence-in-depth layer (Phase 2 of spec 050) that persists safety
335/// events across sessions and runs an LLM probe before high-risk tool execution. It is NOT
336/// the primary security gate — `PolicyGateExecutor` and `TrajectorySentinel` remain the
337/// primary enforcement mechanisms and are unaffected by probe timeouts.
338///
339/// # Example (TOML)
340///
341/// ```toml
342/// [security.shadow_sentinel]
343/// enabled = true
344/// probe_provider = "fast"
345/// probe_timeout_ms = 2000
346/// ```
347#[derive(Debug, Clone, Deserialize, Serialize)]
348pub struct ShadowSentinelConfig {
349    /// Whether the feature is enabled. Default: `false` (opt-in).
350    #[serde(default)]
351    pub enabled: bool,
352    /// Provider name (from `[[llm.providers]]`) used for the safety probe LLM call.
353    ///
354    /// Empty string means use the main/default provider. A fast, cheap provider
355    /// (e.g. `gpt-4o-mini`) is strongly recommended to minimise turn latency.
356    #[serde(default)]
357    pub probe_provider: String,
358    /// Maximum number of trajectory events to include in the probe context. Default: 50.
359    #[serde(default = "default_shadow_max_context_events")]
360    pub max_context_events: usize,
361    /// Timeout for the probe LLM call in milliseconds. Default: 2000.
362    #[serde(default = "default_shadow_probe_timeout_ms")]
363    pub probe_timeout_ms: u64,
364    /// Maximum probe calls per turn to cap LLM costs. Default: 3.
365    #[serde(default = "default_shadow_max_probes_per_turn")]
366    pub max_probes_per_turn: usize,
367    /// Glob patterns over fully-qualified tool ids that trigger the safety probe.
368    ///
369    /// Default covers shell execution, file writes, and MCP file/exec tools.
370    #[serde(default = "default_shadow_probe_patterns")]
371    pub probe_patterns: Vec<String>,
372    /// When `true`, a probe timeout or LLM error causes the tool call to be denied.
373    /// When `false` (default), a probe failure causes the call to be allowed (fail-open).
374    ///
375    /// Fail-open is the correct default because:
376    /// - `ShadowSentinel` is defence-in-depth, not the primary gate.
377    /// - Failing closed on probe timeout would allow a `DoS` (slow context → disabled tools).
378    /// - `PolicyGateExecutor` + `TrajectorySentinel` continue to enforce policy regardless.
379    #[serde(default)]
380    pub deny_on_timeout: bool,
381}
382
383impl Default for ShadowSentinelConfig {
384    fn default() -> Self {
385        Self {
386            enabled: false,
387            probe_provider: String::new(),
388            max_context_events: default_shadow_max_context_events(),
389            probe_timeout_ms: default_shadow_probe_timeout_ms(),
390            max_probes_per_turn: default_shadow_max_probes_per_turn(),
391            probe_patterns: default_shadow_probe_patterns(),
392            deny_on_timeout: false,
393        }
394    }
395}
396
397// ── Capability Scopes ────────────────────────────────────────────────────────
398
399/// Strictness mode for glob pattern matching against the tool registry.
400///
401/// Controls whether a zero-match glob is a fatal error or a warning.
402#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, Default)]
403#[serde(rename_all = "snake_case")]
404pub enum PatternStrictness {
405    /// All namespaces are strict — zero-match globs are fatal.
406    Strict,
407    /// All namespaces are permissive — zero-match globs are warnings only.
408    Permissive,
409    /// `builtin:` and `skill:` globs are strict; `mcp:`, `acp:`, `a2a:` are provisional.
410    ///
411    /// This is the default because MCP servers may not be connected at startup.
412    #[default]
413    ProvisionalForDynamicNamespaces,
414}
415
416/// Configuration for a single task-type scope, nested under
417/// `[security.capability_scopes.<task_type>]`.
418///
419/// # Example (TOML)
420///
421/// ```toml
422/// [security.capability_scopes.research]
423/// patterns = ["builtin:fetch", "builtin:web_scrape", "builtin:search_*"]
424/// ```
425#[derive(Debug, Clone, Deserialize, Serialize)]
426pub struct ScopeConfig {
427    /// Glob patterns over fully-qualified tool ids (`<namespace>:<tool>`).
428    ///
429    /// Evaluated against the materialised tool registry at agent build time.
430    #[serde(default)]
431    pub patterns: Vec<String>,
432}
433
434/// Top-level capability scopes configuration, nested under `[security.capability_scopes]`.
435///
436/// # Example (TOML)
437///
438/// ```toml
439/// [security.capability_scopes]
440/// default_scope = "general"
441/// strict = true
442///
443/// [security.capability_scopes.general]
444/// patterns = ["*"]
445///
446/// [security.capability_scopes.research]
447/// patterns = ["builtin:fetch", "builtin:web_scrape", "builtin:search_*", "builtin:read"]
448///
449/// [security.capability_scopes.code_edit]
450/// patterns = ["builtin:read", "builtin:edit", "builtin:write", "builtin:shell", "builtin:glob"]
451/// ```
452#[derive(Debug, Clone, Deserialize, Serialize, Default)]
453pub struct CapabilityScopesConfig {
454    /// Name of the scope used when no task type is specified. Default: `"general"`.
455    ///
456    /// When `default_scope = "general"` and a `[security.capability_scopes.general]` section
457    /// with `patterns = ["*"]` exists, scoping is a no-op identity (full tool set surfaced).
458    #[serde(default = "default_scope_name")]
459    pub default_scope: String,
460    /// When `true`, an unrecognised `task_type` is a fatal startup error.
461    /// When `false`, falls back to `default_scope`. Default: `false`.
462    #[serde(default)]
463    pub strict: bool,
464    /// Per-namespace strictness for zero-match glob patterns.
465    #[serde(default)]
466    pub pattern_strictness: PatternStrictness,
467    /// Named scopes. Keys are task-type names; values are their scope configurations.
468    #[serde(default, flatten)]
469    pub scopes: HashMap<String, ScopeConfig>,
470}
471
472fn default_scope_name() -> String {
473    "general".to_owned()
474}
475
476// ── Agent security configuration ─────────────────────────────────────────────
477
478/// Agent security configuration, nested under `[security]` in TOML.
479///
480/// Aggregates all security-related subsystems: content isolation, exfiltration guards,
481/// memory write validation, PII filtering, rate limiting, prompt injection screening,
482/// and response verification.
483///
484/// # Example (TOML)
485///
486/// ```toml
487/// [security]
488/// redact_secrets = true
489/// autonomy_level = "moderate"
490///
491/// [security.rate_limit]
492/// enabled = true
493/// shell_calls_per_minute = 20
494/// ```
495#[derive(Debug, Clone, Deserialize, Serialize)]
496pub struct SecurityConfig {
497    /// Automatically redact detected secrets from tool outputs before they reach the LLM.
498    /// Default: `true`.
499    #[serde(default = "default_true")]
500    pub redact_secrets: bool,
501    /// Autonomy level controlling which tool actions require explicit user confirmation.
502    #[serde(default)]
503    pub autonomy_level: AutonomyLevel,
504    #[serde(default)]
505    pub content_isolation: ContentIsolationConfig,
506    #[serde(default)]
507    pub exfiltration_guard: ExfiltrationGuardConfig,
508    /// Memory write validation (enabled by default).
509    #[serde(default)]
510    pub memory_validation: MemoryWriteValidationConfig,
511    /// PII filter for tool outputs and debug dumps (opt-in, disabled by default).
512    #[serde(default)]
513    pub pii_filter: PiiFilterConfig,
514    /// Tool action rate limiter (opt-in, disabled by default).
515    #[serde(default)]
516    pub rate_limit: RateLimitConfig,
517    /// Pre-execution verifiers (enabled by default).
518    #[serde(default)]
519    pub pre_execution_verify: PreExecutionVerifierConfig,
520    /// LLM-based prompt injection pre-screener (opt-in, disabled by default).
521    #[serde(default)]
522    pub guardrail: GuardrailConfig,
523    /// Post-LLM response verification layer (enabled by default).
524    #[serde(default)]
525    pub response_verification: ResponseVerificationConfig,
526    /// Temporal causal IPI analysis at tool-return boundaries (opt-in, disabled by default).
527    #[serde(default)]
528    pub causal_ipi: CausalIpiConfig,
529    /// VIGIL verify-before-commit intent anchoring gate (enabled by default).
530    ///
531    /// Runs a regex tripwire before `sanitize_tool_output` to intercept low-effort injection
532    /// patterns. See `[[security.vigil]]` in TOML and spec `010-6-vigil-intent-anchoring`.
533    #[serde(default)]
534    pub vigil: VigilConfig,
535    /// Trajectory risk sentinel configuration.
536    ///
537    /// Controls signal decay, risk level thresholds, auto-recovery, and subagent inheritance.
538    /// See spec 050 and `crates/zeph-core/src/agent/trajectory.rs`.
539    #[serde(default)]
540    pub trajectory: TrajectorySentinelConfig,
541    /// Capability scope configuration.
542    ///
543    /// Maps task-type names to glob-pattern allow-lists over fully-qualified tool ids.
544    /// When empty, scoping is a no-op (full tool set surfaced to LLM).
545    #[serde(default)]
546    pub capability_scopes: CapabilityScopesConfig,
547    /// `ShadowSentinel` Phase 2: persistent safety event stream + LLM pre-execution probe.
548    ///
549    /// Disabled by default. When enabled, high-risk tool calls are probed by an LLM
550    /// before execution. `ShadowSentinel` is defence-in-depth only — `PolicyGateExecutor`
551    /// and `TrajectorySentinel` remain the primary enforcement mechanisms.
552    #[serde(default)]
553    pub shadow_sentinel: ShadowSentinelConfig,
554}
555
556impl Default for SecurityConfig {
557    fn default() -> Self {
558        Self {
559            redact_secrets: true,
560            autonomy_level: AutonomyLevel::default(),
561            content_isolation: ContentIsolationConfig::default(),
562            exfiltration_guard: ExfiltrationGuardConfig::default(),
563            memory_validation: MemoryWriteValidationConfig::default(),
564            pii_filter: PiiFilterConfig::default(),
565            rate_limit: RateLimitConfig::default(),
566            pre_execution_verify: PreExecutionVerifierConfig::default(),
567            guardrail: GuardrailConfig::default(),
568            response_verification: ResponseVerificationConfig::default(),
569            causal_ipi: CausalIpiConfig::default(),
570            vigil: VigilConfig::default(),
571            trajectory: TrajectorySentinelConfig::default(),
572            capability_scopes: CapabilityScopesConfig::default(),
573            shadow_sentinel: ShadowSentinelConfig::default(),
574        }
575    }
576}
577
578/// Timeout configuration for external operations, nested under `[timeouts]` in TOML.
579///
580/// All timeouts are in seconds. Exceeding a timeout returns an error to the agent
581/// loop rather than blocking indefinitely.
582///
583/// # Example (TOML)
584///
585/// ```toml
586/// [timeouts]
587/// llm_seconds = 60
588/// embedding_seconds = 15
589/// max_parallel_tools = 4
590/// ```
591#[derive(Debug, Clone, Copy, Deserialize, Serialize)]
592pub struct TimeoutConfig {
593    /// Timeout for streaming LLM first-token responses, in seconds. Default: `120`.
594    #[serde(default = "default_llm_timeout")]
595    pub llm_seconds: u64,
596    /// Total wall-clock timeout for a complete LLM request (all tokens), in seconds.
597    /// Default: `600`.
598    #[serde(default = "default_llm_request_timeout")]
599    pub llm_request_timeout_secs: u64,
600    /// Timeout for embedding API calls, in seconds. Default: `30`.
601    #[serde(default = "default_embedding_timeout")]
602    pub embedding_seconds: u64,
603    /// Timeout for A2A agent-to-agent calls, in seconds. Default: `30`.
604    #[serde(default = "default_a2a_timeout")]
605    pub a2a_seconds: u64,
606    /// Maximum number of tool calls that may execute concurrently in a single turn.
607    /// Default: `8`.
608    #[serde(default = "default_max_parallel_tools")]
609    pub max_parallel_tools: usize,
610    /// Maximum wall-clock time (seconds) allowed for `advance_context_lifecycle` (memory recall,
611    /// graph retrieval, proactive compression, context assembly) before it is aborted and the
612    /// agent proceeds with a degraded (cached) context.
613    ///
614    /// Setting this too low may skip useful memory recall; setting it too high blocks the agent
615    /// when embed providers are rate-limited or unavailable. Default: `30`.
616    #[serde(default = "default_context_prep_timeout")]
617    pub context_prep_timeout_secs: u64,
618    /// How long to wait (seconds) before retrying a turn after the previous turn ended with
619    /// `no providers available`. Prevents a busy-wait loop when all LLM backends are down.
620    /// Default: `2`.
621    #[serde(default = "default_no_providers_backoff_secs")]
622    pub no_providers_backoff_secs: u64,
623}
624
625impl Default for TimeoutConfig {
626    fn default() -> Self {
627        Self {
628            llm_seconds: default_llm_timeout(),
629            llm_request_timeout_secs: default_llm_request_timeout(),
630            embedding_seconds: default_embedding_timeout(),
631            a2a_seconds: default_a2a_timeout(),
632            max_parallel_tools: default_max_parallel_tools(),
633            context_prep_timeout_secs: default_context_prep_timeout(),
634            no_providers_backoff_secs: default_no_providers_backoff_secs(),
635        }
636    }
637}
638
639#[cfg(test)]
640mod tests {
641    use super::*;
642
643    #[test]
644    fn trust_config_default_has_scan_on_load_true() {
645        let config = TrustConfig::default();
646        assert!(config.scan_on_load);
647    }
648
649    #[test]
650    fn trust_config_serde_roundtrip_with_scan_on_load() {
651        let config = TrustConfig {
652            default_level: SkillTrustLevel::Quarantined,
653            local_level: SkillTrustLevel::Trusted,
654            hash_mismatch_level: SkillTrustLevel::Quarantined,
655            bundled_level: SkillTrustLevel::Trusted,
656            scan_on_load: false,
657            scanner: ScannerConfig::default(),
658        };
659        let toml = toml::to_string(&config).expect("serialize");
660        let deserialized: TrustConfig = toml::from_str(&toml).expect("deserialize");
661        assert!(!deserialized.scan_on_load);
662        assert_eq!(deserialized.bundled_level, SkillTrustLevel::Trusted);
663    }
664
665    #[test]
666    fn trust_config_missing_scan_on_load_defaults_to_true() {
667        let toml = r#"
668default_level = "quarantined"
669local_level = "trusted"
670hash_mismatch_level = "quarantined"
671"#;
672        let config: TrustConfig = toml::from_str(toml).expect("deserialize");
673        assert!(
674            config.scan_on_load,
675            "missing scan_on_load must default to true"
676        );
677    }
678
679    #[test]
680    fn trust_config_default_has_bundled_level_trusted() {
681        let config = TrustConfig::default();
682        assert_eq!(config.bundled_level, SkillTrustLevel::Trusted);
683    }
684
685    #[test]
686    fn trust_config_missing_bundled_level_defaults_to_trusted() {
687        let toml = r#"
688default_level = "quarantined"
689local_level = "trusted"
690hash_mismatch_level = "quarantined"
691"#;
692        let config: TrustConfig = toml::from_str(toml).expect("deserialize");
693        assert_eq!(
694            config.bundled_level,
695            SkillTrustLevel::Trusted,
696            "missing bundled_level must default to trusted"
697        );
698    }
699
700    #[test]
701    fn scanner_config_defaults() {
702        let cfg = ScannerConfig::default();
703        assert!(cfg.injection_patterns);
704        assert!(!cfg.capability_escalation_check);
705    }
706
707    #[test]
708    fn scanner_config_serde_roundtrip() {
709        let cfg = ScannerConfig {
710            injection_patterns: false,
711            capability_escalation_check: true,
712        };
713        let toml = toml::to_string(&cfg).expect("serialize");
714        let back: ScannerConfig = toml::from_str(&toml).expect("deserialize");
715        assert!(!back.injection_patterns);
716        assert!(back.capability_escalation_check);
717    }
718
719    #[test]
720    fn trust_config_scanner_defaults_when_missing() {
721        let toml = r#"
722default_level = "quarantined"
723local_level = "trusted"
724hash_mismatch_level = "quarantined"
725"#;
726        let config: TrustConfig = toml::from_str(toml).expect("deserialize");
727        assert!(config.scanner.injection_patterns);
728        assert!(!config.scanner.capability_escalation_check);
729    }
730
731    // ------------------------------------------------------------------
732    // TimeoutConfig — new fields added in #3357
733    // ------------------------------------------------------------------
734
735    #[test]
736    fn timeout_config_context_prep_timeout_default() {
737        let cfg = TimeoutConfig::default();
738        assert_eq!(
739            cfg.context_prep_timeout_secs, 30,
740            "context_prep_timeout_secs default must be 30s (#3357)"
741        );
742    }
743
744    #[test]
745    fn timeout_config_no_providers_backoff_default() {
746        let cfg = TimeoutConfig::default();
747        assert_eq!(
748            cfg.no_providers_backoff_secs, 2,
749            "no_providers_backoff_secs default must be 2s (#3357)"
750        );
751    }
752
753    #[test]
754    fn timeout_config_new_fields_deserialize_from_toml() {
755        let toml = r"
756context_prep_timeout_secs = 60
757no_providers_backoff_secs = 10
758";
759        let cfg: TimeoutConfig = toml::from_str(toml).expect("deserialize");
760        assert_eq!(cfg.context_prep_timeout_secs, 60);
761        assert_eq!(cfg.no_providers_backoff_secs, 10);
762    }
763
764    #[test]
765    fn timeout_config_new_fields_default_when_missing_from_toml() {
766        // An empty TOML section must produce the same values as TimeoutConfig::default().
767        let cfg: TimeoutConfig = toml::from_str("").expect("deserialize empty");
768        assert_eq!(cfg.context_prep_timeout_secs, 30);
769        assert_eq!(cfg.no_providers_backoff_secs, 2);
770    }
771}