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