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