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 serde::{Deserialize, Serialize};
5use zeph_common::SkillTrustLevel;
6
7use crate::tools::{AutonomyLevel, PreExecutionVerifierConfig};
8
9use crate::defaults::default_true;
10use crate::vigil::VigilConfig;
11
12/// Fine-grained controls for the skill body scanner.
13///
14/// Nested under `[skills.trust.scanner]` in TOML.
15#[derive(Debug, Clone, Deserialize, Serialize)]
16pub struct ScannerConfig {
17    /// Scan skill body content for injection patterns at load time.
18    ///
19    /// More specific than `scan_on_load` (which controls whether `scan_loaded()` is called at
20    /// all). When `scan_on_load = true` and `injection_patterns = false`, the scan loop still
21    /// runs but skips the injection pattern check.
22    #[serde(default = "default_true")]
23    pub injection_patterns: bool,
24    /// Check whether a skill's `allowed_tools` exceed its trust level's permissions.
25    ///
26    /// When enabled, the bootstrap calls `check_escalations()` on the registry and logs
27    /// warnings for any tool declarations that violate the trust boundary.
28    #[serde(default)]
29    pub capability_escalation_check: bool,
30}
31
32impl Default for ScannerConfig {
33    fn default() -> Self {
34        Self {
35            injection_patterns: true,
36            capability_escalation_check: false,
37        }
38    }
39}
40use crate::rate_limit::RateLimitConfig;
41use crate::sanitizer::GuardrailConfig;
42use crate::sanitizer::{
43    CausalIpiConfig, ContentIsolationConfig, ExfiltrationGuardConfig, MemoryWriteValidationConfig,
44    PiiFilterConfig, ResponseVerificationConfig,
45};
46
47fn default_trust_default_level() -> SkillTrustLevel {
48    SkillTrustLevel::Quarantined
49}
50
51fn default_trust_local_level() -> SkillTrustLevel {
52    SkillTrustLevel::Trusted
53}
54
55fn default_trust_hash_mismatch_level() -> SkillTrustLevel {
56    SkillTrustLevel::Quarantined
57}
58
59fn default_trust_bundled_level() -> SkillTrustLevel {
60    SkillTrustLevel::Trusted
61}
62
63fn default_llm_timeout() -> u64 {
64    120
65}
66
67fn default_embedding_timeout() -> u64 {
68    30
69}
70
71fn default_a2a_timeout() -> u64 {
72    30
73}
74
75fn default_max_parallel_tools() -> usize {
76    8
77}
78
79fn default_llm_request_timeout() -> u64 {
80    600
81}
82
83fn default_context_prep_timeout() -> u64 {
84    30
85}
86
87fn default_no_providers_backoff_secs() -> u64 {
88    2
89}
90
91/// Skill trust policy configuration, nested under `[skills.trust]` in TOML.
92///
93/// Controls how trust levels are assigned to skills at load time based on their
94/// origin (local filesystem vs network) and integrity (hash verification result).
95///
96/// # Example (TOML)
97///
98/// ```toml
99/// [skills.trust]
100/// default_level = "quarantined"
101/// local_level = "trusted"
102/// scan_on_load = true
103/// ```
104#[derive(Debug, Clone, Deserialize, Serialize)]
105pub struct TrustConfig {
106    /// Trust level assigned to skills from unknown or remote origins. Default: `quarantined`.
107    #[serde(default = "default_trust_default_level")]
108    pub default_level: SkillTrustLevel,
109    /// Trust level assigned to skills found on the local filesystem. Default: `trusted`.
110    #[serde(default = "default_trust_local_level")]
111    pub local_level: SkillTrustLevel,
112    /// Trust level assigned when a skill's content hash does not match the stored hash.
113    /// Default: `quarantined`.
114    #[serde(default = "default_trust_hash_mismatch_level")]
115    pub hash_mismatch_level: SkillTrustLevel,
116    /// Trust level assigned to bundled (built-in) skills shipped with the binary. Default: `trusted`.
117    #[serde(default = "default_trust_bundled_level")]
118    pub bundled_level: SkillTrustLevel,
119    /// Scan skill body content for injection patterns at load time.
120    ///
121    /// When `true`, `SkillRegistry::scan_loaded()` is called at agent startup.
122    /// This is **advisory only** — scan results are logged as warnings and do not
123    /// automatically change trust levels or block tool calls.
124    ///
125    /// Defaults to `true` (secure by default).
126    #[serde(default = "default_true")]
127    pub scan_on_load: bool,
128    /// Fine-grained scanner controls (injection patterns, capability escalation).
129    #[serde(default)]
130    pub scanner: ScannerConfig,
131}
132
133impl Default for TrustConfig {
134    fn default() -> Self {
135        Self {
136            default_level: default_trust_default_level(),
137            local_level: default_trust_local_level(),
138            hash_mismatch_level: default_trust_hash_mismatch_level(),
139            bundled_level: default_trust_bundled_level(),
140            scan_on_load: true,
141            scanner: ScannerConfig::default(),
142        }
143    }
144}
145
146/// Agent security configuration, nested under `[security]` in TOML.
147///
148/// Aggregates all security-related subsystems: content isolation, exfiltration guards,
149/// memory write validation, PII filtering, rate limiting, prompt injection screening,
150/// and response verification.
151///
152/// # Example (TOML)
153///
154/// ```toml
155/// [security]
156/// redact_secrets = true
157/// autonomy_level = "moderate"
158///
159/// [security.rate_limit]
160/// enabled = true
161/// shell_calls_per_minute = 20
162/// ```
163#[derive(Debug, Clone, Deserialize, Serialize)]
164pub struct SecurityConfig {
165    /// Automatically redact detected secrets from tool outputs before they reach the LLM.
166    /// Default: `true`.
167    #[serde(default = "default_true")]
168    pub redact_secrets: bool,
169    /// Autonomy level controlling which tool actions require explicit user confirmation.
170    #[serde(default)]
171    pub autonomy_level: AutonomyLevel,
172    #[serde(default)]
173    pub content_isolation: ContentIsolationConfig,
174    #[serde(default)]
175    pub exfiltration_guard: ExfiltrationGuardConfig,
176    /// Memory write validation (enabled by default).
177    #[serde(default)]
178    pub memory_validation: MemoryWriteValidationConfig,
179    /// PII filter for tool outputs and debug dumps (opt-in, disabled by default).
180    #[serde(default)]
181    pub pii_filter: PiiFilterConfig,
182    /// Tool action rate limiter (opt-in, disabled by default).
183    #[serde(default)]
184    pub rate_limit: RateLimitConfig,
185    /// Pre-execution verifiers (enabled by default).
186    #[serde(default)]
187    pub pre_execution_verify: PreExecutionVerifierConfig,
188    /// LLM-based prompt injection pre-screener (opt-in, disabled by default).
189    #[serde(default)]
190    pub guardrail: GuardrailConfig,
191    /// Post-LLM response verification layer (enabled by default).
192    #[serde(default)]
193    pub response_verification: ResponseVerificationConfig,
194    /// Temporal causal IPI analysis at tool-return boundaries (opt-in, disabled by default).
195    #[serde(default)]
196    pub causal_ipi: CausalIpiConfig,
197    /// VIGIL verify-before-commit intent anchoring gate (enabled by default).
198    ///
199    /// Runs a regex tripwire before `sanitize_tool_output` to intercept low-effort injection
200    /// patterns. See `[[security.vigil]]` in TOML and spec `010-6-vigil-intent-anchoring`.
201    #[serde(default)]
202    pub vigil: VigilConfig,
203}
204
205impl Default for SecurityConfig {
206    fn default() -> Self {
207        Self {
208            redact_secrets: true,
209            autonomy_level: AutonomyLevel::default(),
210            content_isolation: ContentIsolationConfig::default(),
211            exfiltration_guard: ExfiltrationGuardConfig::default(),
212            memory_validation: MemoryWriteValidationConfig::default(),
213            pii_filter: PiiFilterConfig::default(),
214            rate_limit: RateLimitConfig::default(),
215            pre_execution_verify: PreExecutionVerifierConfig::default(),
216            guardrail: GuardrailConfig::default(),
217            response_verification: ResponseVerificationConfig::default(),
218            causal_ipi: CausalIpiConfig::default(),
219            vigil: VigilConfig::default(),
220        }
221    }
222}
223
224/// Timeout configuration for external operations, nested under `[timeouts]` in TOML.
225///
226/// All timeouts are in seconds. Exceeding a timeout returns an error to the agent
227/// loop rather than blocking indefinitely.
228///
229/// # Example (TOML)
230///
231/// ```toml
232/// [timeouts]
233/// llm_seconds = 60
234/// embedding_seconds = 15
235/// max_parallel_tools = 4
236/// ```
237#[derive(Debug, Clone, Copy, Deserialize, Serialize)]
238pub struct TimeoutConfig {
239    /// Timeout for streaming LLM first-token responses, in seconds. Default: `120`.
240    #[serde(default = "default_llm_timeout")]
241    pub llm_seconds: u64,
242    /// Total wall-clock timeout for a complete LLM request (all tokens), in seconds.
243    /// Default: `600`.
244    #[serde(default = "default_llm_request_timeout")]
245    pub llm_request_timeout_secs: u64,
246    /// Timeout for embedding API calls, in seconds. Default: `30`.
247    #[serde(default = "default_embedding_timeout")]
248    pub embedding_seconds: u64,
249    /// Timeout for A2A agent-to-agent calls, in seconds. Default: `30`.
250    #[serde(default = "default_a2a_timeout")]
251    pub a2a_seconds: u64,
252    /// Maximum number of tool calls that may execute concurrently in a single turn.
253    /// Default: `8`.
254    #[serde(default = "default_max_parallel_tools")]
255    pub max_parallel_tools: usize,
256    /// Maximum wall-clock time (seconds) allowed for `advance_context_lifecycle` (memory recall,
257    /// graph retrieval, proactive compression, context assembly) before it is aborted and the
258    /// agent proceeds with a degraded (cached) context.
259    ///
260    /// Setting this too low may skip useful memory recall; setting it too high blocks the agent
261    /// when embed providers are rate-limited or unavailable. Default: `30`.
262    #[serde(default = "default_context_prep_timeout")]
263    pub context_prep_timeout_secs: u64,
264    /// How long to wait (seconds) before retrying a turn after the previous turn ended with
265    /// `no providers available`. Prevents a busy-wait loop when all LLM backends are down.
266    /// Default: `2`.
267    #[serde(default = "default_no_providers_backoff_secs")]
268    pub no_providers_backoff_secs: u64,
269}
270
271impl Default for TimeoutConfig {
272    fn default() -> Self {
273        Self {
274            llm_seconds: default_llm_timeout(),
275            llm_request_timeout_secs: default_llm_request_timeout(),
276            embedding_seconds: default_embedding_timeout(),
277            a2a_seconds: default_a2a_timeout(),
278            max_parallel_tools: default_max_parallel_tools(),
279            context_prep_timeout_secs: default_context_prep_timeout(),
280            no_providers_backoff_secs: default_no_providers_backoff_secs(),
281        }
282    }
283}
284
285#[cfg(test)]
286mod tests {
287    use super::*;
288
289    #[test]
290    fn trust_config_default_has_scan_on_load_true() {
291        let config = TrustConfig::default();
292        assert!(config.scan_on_load);
293    }
294
295    #[test]
296    fn trust_config_serde_roundtrip_with_scan_on_load() {
297        let config = TrustConfig {
298            default_level: SkillTrustLevel::Quarantined,
299            local_level: SkillTrustLevel::Trusted,
300            hash_mismatch_level: SkillTrustLevel::Quarantined,
301            bundled_level: SkillTrustLevel::Trusted,
302            scan_on_load: false,
303            scanner: ScannerConfig::default(),
304        };
305        let toml = toml::to_string(&config).expect("serialize");
306        let deserialized: TrustConfig = toml::from_str(&toml).expect("deserialize");
307        assert!(!deserialized.scan_on_load);
308        assert_eq!(deserialized.bundled_level, SkillTrustLevel::Trusted);
309    }
310
311    #[test]
312    fn trust_config_missing_scan_on_load_defaults_to_true() {
313        let toml = r#"
314default_level = "quarantined"
315local_level = "trusted"
316hash_mismatch_level = "quarantined"
317"#;
318        let config: TrustConfig = toml::from_str(toml).expect("deserialize");
319        assert!(
320            config.scan_on_load,
321            "missing scan_on_load must default to true"
322        );
323    }
324
325    #[test]
326    fn trust_config_default_has_bundled_level_trusted() {
327        let config = TrustConfig::default();
328        assert_eq!(config.bundled_level, SkillTrustLevel::Trusted);
329    }
330
331    #[test]
332    fn trust_config_missing_bundled_level_defaults_to_trusted() {
333        let toml = r#"
334default_level = "quarantined"
335local_level = "trusted"
336hash_mismatch_level = "quarantined"
337"#;
338        let config: TrustConfig = toml::from_str(toml).expect("deserialize");
339        assert_eq!(
340            config.bundled_level,
341            SkillTrustLevel::Trusted,
342            "missing bundled_level must default to trusted"
343        );
344    }
345
346    #[test]
347    fn scanner_config_defaults() {
348        let cfg = ScannerConfig::default();
349        assert!(cfg.injection_patterns);
350        assert!(!cfg.capability_escalation_check);
351    }
352
353    #[test]
354    fn scanner_config_serde_roundtrip() {
355        let cfg = ScannerConfig {
356            injection_patterns: false,
357            capability_escalation_check: true,
358        };
359        let toml = toml::to_string(&cfg).expect("serialize");
360        let back: ScannerConfig = toml::from_str(&toml).expect("deserialize");
361        assert!(!back.injection_patterns);
362        assert!(back.capability_escalation_check);
363    }
364
365    #[test]
366    fn trust_config_scanner_defaults_when_missing() {
367        let toml = r#"
368default_level = "quarantined"
369local_level = "trusted"
370hash_mismatch_level = "quarantined"
371"#;
372        let config: TrustConfig = toml::from_str(toml).expect("deserialize");
373        assert!(config.scanner.injection_patterns);
374        assert!(!config.scanner.capability_escalation_check);
375    }
376
377    // ------------------------------------------------------------------
378    // TimeoutConfig — new fields added in #3357
379    // ------------------------------------------------------------------
380
381    #[test]
382    fn timeout_config_context_prep_timeout_default() {
383        let cfg = TimeoutConfig::default();
384        assert_eq!(
385            cfg.context_prep_timeout_secs, 30,
386            "context_prep_timeout_secs default must be 30s (#3357)"
387        );
388    }
389
390    #[test]
391    fn timeout_config_no_providers_backoff_default() {
392        let cfg = TimeoutConfig::default();
393        assert_eq!(
394            cfg.no_providers_backoff_secs, 2,
395            "no_providers_backoff_secs default must be 2s (#3357)"
396        );
397    }
398
399    #[test]
400    fn timeout_config_new_fields_deserialize_from_toml() {
401        let toml = r"
402context_prep_timeout_secs = 60
403no_providers_backoff_secs = 10
404";
405        let cfg: TimeoutConfig = toml::from_str(toml).expect("deserialize");
406        assert_eq!(cfg.context_prep_timeout_secs, 60);
407        assert_eq!(cfg.no_providers_backoff_secs, 10);
408    }
409
410    #[test]
411    fn timeout_config_new_fields_default_when_missing_from_toml() {
412        // An empty TOML section must produce the same values as TimeoutConfig::default().
413        let cfg: TimeoutConfig = toml::from_str("").expect("deserialize empty");
414        assert_eq!(cfg.context_prep_timeout_secs, 30);
415        assert_eq!(cfg.no_providers_backoff_secs, 2);
416    }
417}