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}