Skip to main content

zeph_tools/
config.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use serde::{Deserialize, Serialize};
5
6use crate::permissions::{AutonomyLevel, PermissionPolicy, PermissionsConfig};
7#[cfg(feature = "policy-enforcer")]
8use crate::policy::PolicyConfig;
9
10fn default_true() -> bool {
11    true
12}
13
14#[cfg(feature = "policy-enforcer")]
15fn default_adversarial_timeout_ms() -> u64 {
16    3_000
17}
18
19fn default_timeout() -> u64 {
20    30
21}
22
23fn default_cache_ttl_secs() -> u64 {
24    300
25}
26
27fn default_confirm_patterns() -> Vec<String> {
28    vec![
29        "rm ".into(),
30        "git push -f".into(),
31        "git push --force".into(),
32        "drop table".into(),
33        "drop database".into(),
34        "truncate ".into(),
35        "$(".into(),
36        "`".into(),
37        "<(".into(),
38        ">(".into(),
39        "<<<".into(),
40        "eval ".into(),
41    ]
42}
43
44fn default_audit_destination() -> String {
45    "stdout".into()
46}
47
48fn default_overflow_threshold() -> usize {
49    50_000
50}
51
52fn default_retention_days() -> u64 {
53    7
54}
55
56fn default_max_overflow_bytes() -> usize {
57    10 * 1024 * 1024 // 10 MiB
58}
59
60/// Configuration for large tool response offload to `SQLite`.
61#[derive(Debug, Clone, Deserialize, Serialize)]
62pub struct OverflowConfig {
63    #[serde(default = "default_overflow_threshold")]
64    pub threshold: usize,
65    #[serde(default = "default_retention_days")]
66    pub retention_days: u64,
67    /// Maximum bytes per overflow entry. `0` means unlimited.
68    #[serde(default = "default_max_overflow_bytes")]
69    pub max_overflow_bytes: usize,
70}
71
72impl Default for OverflowConfig {
73    fn default() -> Self {
74        Self {
75            threshold: default_overflow_threshold(),
76            retention_days: default_retention_days(),
77            max_overflow_bytes: default_max_overflow_bytes(),
78        }
79    }
80}
81
82fn default_anomaly_window() -> usize {
83    10
84}
85
86fn default_anomaly_error_threshold() -> f64 {
87    0.5
88}
89
90fn default_anomaly_critical_threshold() -> f64 {
91    0.8
92}
93
94/// Configuration for the sliding-window anomaly detector.
95#[derive(Debug, Clone, Deserialize, Serialize)]
96pub struct AnomalyConfig {
97    #[serde(default = "default_true")]
98    pub enabled: bool,
99    #[serde(default = "default_anomaly_window")]
100    pub window_size: usize,
101    #[serde(default = "default_anomaly_error_threshold")]
102    pub error_threshold: f64,
103    #[serde(default = "default_anomaly_critical_threshold")]
104    pub critical_threshold: f64,
105    /// Emit a WARN log when a reasoning-enhanced model (o1, o3, `QwQ`, etc.) produces
106    /// a quality failure (`ToolNotFound`, `InvalidParameters`, `TypeMismatch`). Default: `true`.
107    ///
108    /// Based on arXiv:2510.22977 — CoT/RL reasoning amplifies tool hallucination.
109    #[serde(default = "default_true")]
110    pub reasoning_model_warning: bool,
111}
112
113impl Default for AnomalyConfig {
114    fn default() -> Self {
115        Self {
116            enabled: true,
117            window_size: default_anomaly_window(),
118            error_threshold: default_anomaly_error_threshold(),
119            critical_threshold: default_anomaly_critical_threshold(),
120            reasoning_model_warning: true,
121        }
122    }
123}
124
125/// Configuration for the tool result cache.
126#[derive(Debug, Clone, Deserialize, Serialize)]
127pub struct ResultCacheConfig {
128    /// Whether caching is enabled. Default: `true`.
129    #[serde(default = "default_true")]
130    pub enabled: bool,
131    /// Time-to-live in seconds. `0` means entries never expire. Default: `300`.
132    #[serde(default = "default_cache_ttl_secs")]
133    pub ttl_secs: u64,
134}
135
136impl Default for ResultCacheConfig {
137    fn default() -> Self {
138        Self {
139            enabled: true,
140            ttl_secs: default_cache_ttl_secs(),
141        }
142    }
143}
144
145fn default_tafc_complexity_threshold() -> f64 {
146    0.6
147}
148
149/// Configuration for Think-Augmented Function Calling (TAFC).
150#[derive(Debug, Clone, Deserialize, Serialize)]
151pub struct TafcConfig {
152    /// Enable TAFC schema augmentation (default: false).
153    #[serde(default)]
154    pub enabled: bool,
155    /// Complexity threshold tau in [0.0, 1.0]; tools with complexity >= tau are augmented.
156    /// Default: 0.6
157    #[serde(default = "default_tafc_complexity_threshold")]
158    pub complexity_threshold: f64,
159}
160
161impl Default for TafcConfig {
162    fn default() -> Self {
163        Self {
164            enabled: false,
165            complexity_threshold: default_tafc_complexity_threshold(),
166        }
167    }
168}
169
170impl TafcConfig {
171    /// Validate and clamp `complexity_threshold` to \[0.0, 1.0\]. Reset NaN/Infinity to 0.6.
172    #[must_use]
173    pub fn validated(mut self) -> Self {
174        if self.complexity_threshold.is_finite() {
175            self.complexity_threshold = self.complexity_threshold.clamp(0.0, 1.0);
176        } else {
177            self.complexity_threshold = 0.6;
178        }
179        self
180    }
181}
182
183fn default_utility_threshold() -> f32 {
184    0.1
185}
186
187fn default_utility_gain_weight() -> f32 {
188    1.0
189}
190
191fn default_utility_cost_weight() -> f32 {
192    0.5
193}
194
195fn default_utility_redundancy_weight() -> f32 {
196    0.3
197}
198
199fn default_utility_uncertainty_bonus() -> f32 {
200    0.2
201}
202
203/// Configuration for utility-guided tool dispatch (`[tools.utility]` TOML section).
204///
205/// Implements the utility gate from arXiv:2603.19896: each tool call is scored
206/// `U = gain_weight*gain - cost_weight*cost - redundancy_weight*redundancy + uncertainty_bonus*uncertainty`.
207/// Calls with `U < threshold` are skipped (fail-closed on scoring errors).
208#[derive(Debug, Clone, Deserialize, Serialize)]
209#[serde(default)]
210pub struct UtilityScoringConfig {
211    /// Enable utility-guided gating. Default: false (opt-in).
212    pub enabled: bool,
213    /// Minimum utility score required to execute a tool call. Default: 0.1.
214    #[serde(default = "default_utility_threshold")]
215    pub threshold: f32,
216    /// Weight for the estimated gain component. Must be >= 0. Default: 1.0.
217    #[serde(default = "default_utility_gain_weight")]
218    pub gain_weight: f32,
219    /// Weight for the step cost component. Must be >= 0. Default: 0.5.
220    #[serde(default = "default_utility_cost_weight")]
221    pub cost_weight: f32,
222    /// Weight for the redundancy penalty. Must be >= 0. Default: 0.3.
223    #[serde(default = "default_utility_redundancy_weight")]
224    pub redundancy_weight: f32,
225    /// Weight for the exploration bonus. Must be >= 0. Default: 0.2.
226    #[serde(default = "default_utility_uncertainty_bonus")]
227    pub uncertainty_bonus: f32,
228}
229
230impl Default for UtilityScoringConfig {
231    fn default() -> Self {
232        Self {
233            enabled: false,
234            threshold: default_utility_threshold(),
235            gain_weight: default_utility_gain_weight(),
236            cost_weight: default_utility_cost_weight(),
237            redundancy_weight: default_utility_redundancy_weight(),
238            uncertainty_bonus: default_utility_uncertainty_bonus(),
239        }
240    }
241}
242
243impl UtilityScoringConfig {
244    /// Validate that all weights and threshold are non-negative and finite.
245    ///
246    /// # Errors
247    ///
248    /// Returns a description of the first invalid field found.
249    pub fn validate(&self) -> Result<(), String> {
250        let fields = [
251            ("threshold", self.threshold),
252            ("gain_weight", self.gain_weight),
253            ("cost_weight", self.cost_weight),
254            ("redundancy_weight", self.redundancy_weight),
255            ("uncertainty_bonus", self.uncertainty_bonus),
256        ];
257        for (name, val) in fields {
258            if !val.is_finite() {
259                return Err(format!("[tools.utility] {name} must be finite, got {val}"));
260            }
261            if val < 0.0 {
262                return Err(format!("[tools.utility] {name} must be >= 0, got {val}"));
263            }
264        }
265        Ok(())
266    }
267}
268
269fn default_boost_per_dep() -> f32 {
270    0.15
271}
272
273fn default_max_total_boost() -> f32 {
274    0.2
275}
276
277/// Dependency specification for a single tool.
278#[derive(Debug, Clone, Default, Deserialize, Serialize)]
279pub struct ToolDependency {
280    /// Hard prerequisites: tool is hidden until ALL of these have completed successfully.
281    #[serde(default, skip_serializing_if = "Vec::is_empty")]
282    pub requires: Vec<String>,
283    /// Soft prerequisites: tool gets a similarity boost when these have completed.
284    #[serde(default, skip_serializing_if = "Vec::is_empty")]
285    pub prefers: Vec<String>,
286}
287
288/// Configuration for the tool dependency graph feature.
289#[derive(Debug, Clone, Deserialize, Serialize)]
290pub struct DependencyConfig {
291    /// Whether dependency gating is enabled. Default: false.
292    #[serde(default)]
293    pub enabled: bool,
294    /// Similarity boost added per satisfied `prefers` dependency. Default: 0.15.
295    #[serde(default = "default_boost_per_dep")]
296    pub boost_per_dep: f32,
297    /// Maximum total boost applied regardless of how many `prefers` deps are met. Default: 0.2.
298    #[serde(default = "default_max_total_boost")]
299    pub max_total_boost: f32,
300    /// Per-tool dependency rules. Key is `tool_id`.
301    #[serde(default)]
302    pub rules: std::collections::HashMap<String, ToolDependency>,
303}
304
305impl Default for DependencyConfig {
306    fn default() -> Self {
307        Self {
308            enabled: false,
309            boost_per_dep: default_boost_per_dep(),
310            max_total_boost: default_max_total_boost(),
311            rules: std::collections::HashMap::new(),
312        }
313    }
314}
315
316fn default_retry_max_attempts() -> usize {
317    2
318}
319
320fn default_retry_base_ms() -> u64 {
321    500
322}
323
324fn default_retry_max_ms() -> u64 {
325    5_000
326}
327
328fn default_retry_budget_secs() -> u64 {
329    30
330}
331
332/// Configuration for tool error retry behavior.
333#[derive(Debug, Clone, Deserialize, Serialize)]
334pub struct RetryConfig {
335    /// Maximum retry attempts for transient errors per tool call. 0 = disabled.
336    #[serde(default = "default_retry_max_attempts")]
337    pub max_attempts: usize,
338    /// Base delay (ms) for exponential backoff.
339    #[serde(default = "default_retry_base_ms")]
340    pub base_ms: u64,
341    /// Maximum delay cap (ms) for exponential backoff.
342    #[serde(default = "default_retry_max_ms")]
343    pub max_ms: u64,
344    /// Maximum wall-clock time (seconds) for all retries of a single tool call. 0 = unlimited.
345    #[serde(default = "default_retry_budget_secs")]
346    pub budget_secs: u64,
347    /// Provider name from `[[llm.providers]]` for LLM-based parameter reformatting on
348    /// `InvalidParameters`/`TypeMismatch` errors. Empty string = disabled.
349    #[serde(default)]
350    pub parameter_reformat_provider: String,
351}
352
353impl Default for RetryConfig {
354    fn default() -> Self {
355        Self {
356            max_attempts: default_retry_max_attempts(),
357            base_ms: default_retry_base_ms(),
358            max_ms: default_retry_max_ms(),
359            budget_secs: default_retry_budget_secs(),
360            parameter_reformat_provider: String::new(),
361        }
362    }
363}
364
365/// Configuration for the LLM-based adversarial policy agent.
366#[cfg(feature = "policy-enforcer")]
367#[derive(Debug, Clone, Deserialize, Serialize)]
368pub struct AdversarialPolicyConfig {
369    /// Enable the adversarial policy agent. Default: `false`.
370    #[serde(default)]
371    pub enabled: bool,
372    /// Provider name from `[[llm.providers]]` for the policy validation LLM.
373    /// Should reference a fast, cheap model (e.g. `gpt-4o-mini`).
374    /// Empty string = fall back to the default provider.
375    #[serde(default)]
376    pub policy_provider: String,
377    /// Path to a plain-text policy file. Each non-empty, non-comment line is one policy.
378    pub policy_file: Option<String>,
379    /// Whether to allow tool calls when the policy LLM fails (timeout/error).
380    /// Default: `false` (fail-closed / deny on error).
381    ///
382    /// Setting this to `true` trades security for availability. Use only in
383    /// deployments where the declarative `PolicyEnforcer` already covers hard rules.
384    #[serde(default)]
385    pub fail_open: bool,
386    /// Timeout in milliseconds for a single policy LLM call. Default: 3000.
387    #[serde(default = "default_adversarial_timeout_ms")]
388    pub timeout_ms: u64,
389    /// Tool names that are always allowed through the adversarial policy gate,
390    /// regardless of policy content. Covers internal agent operations that are
391    /// not externally visible side effects.
392    #[serde(default = "AdversarialPolicyConfig::default_exempt_tools")]
393    pub exempt_tools: Vec<String>,
394}
395
396#[cfg(feature = "policy-enforcer")]
397impl Default for AdversarialPolicyConfig {
398    fn default() -> Self {
399        Self {
400            enabled: false,
401            policy_provider: String::new(),
402            policy_file: None,
403            fail_open: false,
404            timeout_ms: default_adversarial_timeout_ms(),
405            exempt_tools: Self::default_exempt_tools(),
406        }
407    }
408}
409
410#[cfg(feature = "policy-enforcer")]
411impl AdversarialPolicyConfig {
412    fn default_exempt_tools() -> Vec<String> {
413        vec![
414            "memory_save".into(),
415            "memory_search".into(),
416            "read_overflow".into(),
417            "load_skill".into(),
418            "schedule_deferred".into(),
419        ]
420    }
421}
422
423/// Top-level configuration for tool execution.
424#[derive(Debug, Deserialize, Serialize)]
425pub struct ToolsConfig {
426    #[serde(default = "default_true")]
427    pub enabled: bool,
428    #[serde(default = "default_true")]
429    pub summarize_output: bool,
430    #[serde(default)]
431    pub shell: ShellConfig,
432    #[serde(default)]
433    pub scrape: ScrapeConfig,
434    #[serde(default)]
435    pub audit: AuditConfig,
436    #[serde(default)]
437    pub permissions: Option<PermissionsConfig>,
438    #[serde(default)]
439    pub filters: crate::filter::FilterConfig,
440    #[serde(default)]
441    pub overflow: OverflowConfig,
442    #[serde(default)]
443    pub anomaly: AnomalyConfig,
444    #[serde(default)]
445    pub result_cache: ResultCacheConfig,
446    #[serde(default)]
447    pub tafc: TafcConfig,
448    #[serde(default)]
449    pub dependencies: DependencyConfig,
450    #[serde(default)]
451    pub retry: RetryConfig,
452    /// Declarative policy compiler for tool call authorization.
453    #[cfg(feature = "policy-enforcer")]
454    #[serde(default)]
455    pub policy: PolicyConfig,
456    /// LLM-based adversarial policy agent for natural-language policy enforcement.
457    #[cfg(feature = "policy-enforcer")]
458    #[serde(default)]
459    pub adversarial_policy: AdversarialPolicyConfig,
460    /// Utility-guided tool dispatch gate.
461    #[serde(default)]
462    pub utility: UtilityScoringConfig,
463}
464
465impl ToolsConfig {
466    /// Build a `PermissionPolicy` from explicit config or legacy shell fields.
467    #[must_use]
468    pub fn permission_policy(&self, autonomy_level: AutonomyLevel) -> PermissionPolicy {
469        let policy = if let Some(ref perms) = self.permissions {
470            PermissionPolicy::from(perms.clone())
471        } else {
472            PermissionPolicy::from_legacy(
473                &self.shell.blocked_commands,
474                &self.shell.confirm_patterns,
475            )
476        };
477        policy.with_autonomy(autonomy_level)
478    }
479}
480
481/// Shell-specific configuration: timeout, command blocklist, and allowlist overrides.
482#[derive(Debug, Deserialize, Serialize)]
483#[allow(clippy::struct_excessive_bools)]
484pub struct ShellConfig {
485    #[serde(default = "default_timeout")]
486    pub timeout: u64,
487    #[serde(default)]
488    pub blocked_commands: Vec<String>,
489    #[serde(default)]
490    pub allowed_commands: Vec<String>,
491    #[serde(default)]
492    pub allowed_paths: Vec<String>,
493    #[serde(default = "default_true")]
494    pub allow_network: bool,
495    #[serde(default = "default_confirm_patterns")]
496    pub confirm_patterns: Vec<String>,
497    /// Environment variable name prefixes to strip from subprocess environment.
498    /// Variables whose names start with any of these prefixes are removed before
499    /// spawning shell commands. Default covers common credential naming conventions.
500    #[serde(default = "ShellConfig::default_env_blocklist")]
501    pub env_blocklist: Vec<String>,
502    /// Enable transactional mode: snapshot files before write commands, rollback on failure.
503    #[serde(default)]
504    pub transactional: bool,
505    /// Glob patterns defining which paths are eligible for snapshotting.
506    /// Only files matching these patterns (relative to cwd) are captured.
507    /// Empty = snapshot all files referenced in the command.
508    #[serde(default)]
509    pub transaction_scope: Vec<String>,
510    /// Automatically rollback when exit code >= 2. Default: false.
511    /// Exit code 1 is excluded because many tools (grep, diff, test) use it for
512    /// non-error conditions.
513    #[serde(default)]
514    pub auto_rollback: bool,
515    /// Exit codes that trigger auto-rollback. Default: empty (uses >= 2 heuristic).
516    /// When non-empty, only these exact exit codes trigger rollback.
517    #[serde(default)]
518    pub auto_rollback_exit_codes: Vec<i32>,
519    /// When true, snapshot failure aborts execution with an error.
520    /// When false (default), snapshot failure emits a warning and execution proceeds.
521    #[serde(default)]
522    pub snapshot_required: bool,
523    /// Maximum cumulative bytes for transaction snapshots. 0 = unlimited.
524    #[serde(default)]
525    pub max_snapshot_bytes: u64,
526}
527
528impl ShellConfig {
529    #[must_use]
530    pub fn default_env_blocklist() -> Vec<String> {
531        vec![
532            "ZEPH_".into(),
533            "AWS_".into(),
534            "AZURE_".into(),
535            "GCP_".into(),
536            "GOOGLE_".into(),
537            "OPENAI_".into(),
538            "ANTHROPIC_".into(),
539            "HF_".into(),
540            "HUGGING".into(),
541        ]
542    }
543}
544
545/// Configuration for audit logging of tool executions.
546#[derive(Debug, Deserialize, Serialize)]
547pub struct AuditConfig {
548    #[serde(default = "default_true")]
549    pub enabled: bool,
550    #[serde(default = "default_audit_destination")]
551    pub destination: String,
552}
553
554impl Default for ToolsConfig {
555    fn default() -> Self {
556        Self {
557            enabled: true,
558            summarize_output: true,
559            shell: ShellConfig::default(),
560            scrape: ScrapeConfig::default(),
561            audit: AuditConfig::default(),
562            permissions: None,
563            filters: crate::filter::FilterConfig::default(),
564            overflow: OverflowConfig::default(),
565            anomaly: AnomalyConfig::default(),
566            result_cache: ResultCacheConfig::default(),
567            tafc: TafcConfig::default(),
568            dependencies: DependencyConfig::default(),
569            retry: RetryConfig::default(),
570            #[cfg(feature = "policy-enforcer")]
571            policy: PolicyConfig::default(),
572            #[cfg(feature = "policy-enforcer")]
573            adversarial_policy: AdversarialPolicyConfig::default(),
574            utility: UtilityScoringConfig::default(),
575        }
576    }
577}
578
579impl Default for ShellConfig {
580    fn default() -> Self {
581        Self {
582            timeout: default_timeout(),
583            blocked_commands: Vec::new(),
584            allowed_commands: Vec::new(),
585            allowed_paths: Vec::new(),
586            allow_network: true,
587            confirm_patterns: default_confirm_patterns(),
588            env_blocklist: Self::default_env_blocklist(),
589            transactional: false,
590            transaction_scope: Vec::new(),
591            auto_rollback: false,
592            auto_rollback_exit_codes: Vec::new(),
593            snapshot_required: false,
594            max_snapshot_bytes: 0,
595        }
596    }
597}
598
599impl Default for AuditConfig {
600    fn default() -> Self {
601        Self {
602            enabled: true,
603            destination: default_audit_destination(),
604        }
605    }
606}
607
608fn default_scrape_timeout() -> u64 {
609    15
610}
611
612fn default_max_body_bytes() -> usize {
613    4_194_304
614}
615
616/// Configuration for the web scrape tool.
617#[derive(Debug, Deserialize, Serialize)]
618pub struct ScrapeConfig {
619    #[serde(default = "default_scrape_timeout")]
620    pub timeout: u64,
621    #[serde(default = "default_max_body_bytes")]
622    pub max_body_bytes: usize,
623}
624
625impl Default for ScrapeConfig {
626    fn default() -> Self {
627        Self {
628            timeout: default_scrape_timeout(),
629            max_body_bytes: default_max_body_bytes(),
630        }
631    }
632}
633
634#[cfg(test)]
635mod tests {
636    use super::*;
637
638    #[test]
639    fn deserialize_default_config() {
640        let toml_str = r#"
641            enabled = true
642
643            [shell]
644            timeout = 60
645            blocked_commands = ["rm -rf /", "sudo"]
646        "#;
647
648        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
649        assert!(config.enabled);
650        assert_eq!(config.shell.timeout, 60);
651        assert_eq!(config.shell.blocked_commands.len(), 2);
652        assert_eq!(config.shell.blocked_commands[0], "rm -rf /");
653        assert_eq!(config.shell.blocked_commands[1], "sudo");
654    }
655
656    #[test]
657    fn empty_blocked_commands() {
658        let toml_str = r"
659            [shell]
660            timeout = 30
661        ";
662
663        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
664        assert!(config.enabled);
665        assert_eq!(config.shell.timeout, 30);
666        assert!(config.shell.blocked_commands.is_empty());
667    }
668
669    #[test]
670    fn default_tools_config() {
671        let config = ToolsConfig::default();
672        assert!(config.enabled);
673        assert!(config.summarize_output);
674        assert_eq!(config.shell.timeout, 30);
675        assert!(config.shell.blocked_commands.is_empty());
676        assert!(config.audit.enabled);
677    }
678
679    #[test]
680    fn tools_summarize_output_default_true() {
681        let config = ToolsConfig::default();
682        assert!(config.summarize_output);
683    }
684
685    #[test]
686    fn tools_summarize_output_parsing() {
687        let toml_str = r"
688            summarize_output = true
689        ";
690        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
691        assert!(config.summarize_output);
692    }
693
694    #[test]
695    fn default_shell_config() {
696        let config = ShellConfig::default();
697        assert_eq!(config.timeout, 30);
698        assert!(config.blocked_commands.is_empty());
699        assert!(config.allowed_paths.is_empty());
700        assert!(config.allow_network);
701        assert!(!config.confirm_patterns.is_empty());
702    }
703
704    #[test]
705    fn deserialize_omitted_fields_use_defaults() {
706        let toml_str = "";
707        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
708        assert!(config.enabled);
709        assert_eq!(config.shell.timeout, 30);
710        assert!(config.shell.blocked_commands.is_empty());
711        assert!(config.shell.allow_network);
712        assert!(!config.shell.confirm_patterns.is_empty());
713        assert_eq!(config.scrape.timeout, 15);
714        assert_eq!(config.scrape.max_body_bytes, 4_194_304);
715        assert!(config.audit.enabled);
716        assert_eq!(config.audit.destination, "stdout");
717        assert!(config.summarize_output);
718    }
719
720    #[test]
721    fn default_scrape_config() {
722        let config = ScrapeConfig::default();
723        assert_eq!(config.timeout, 15);
724        assert_eq!(config.max_body_bytes, 4_194_304);
725    }
726
727    #[test]
728    fn deserialize_scrape_config() {
729        let toml_str = r"
730            [scrape]
731            timeout = 30
732            max_body_bytes = 2097152
733        ";
734
735        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
736        assert_eq!(config.scrape.timeout, 30);
737        assert_eq!(config.scrape.max_body_bytes, 2_097_152);
738    }
739
740    #[test]
741    fn tools_config_default_includes_scrape() {
742        let config = ToolsConfig::default();
743        assert_eq!(config.scrape.timeout, 15);
744        assert_eq!(config.scrape.max_body_bytes, 4_194_304);
745    }
746
747    #[test]
748    fn deserialize_allowed_commands() {
749        let toml_str = r#"
750            [shell]
751            timeout = 30
752            allowed_commands = ["curl", "wget"]
753        "#;
754
755        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
756        assert_eq!(config.shell.allowed_commands, vec!["curl", "wget"]);
757    }
758
759    #[test]
760    fn default_allowed_commands_empty() {
761        let config = ShellConfig::default();
762        assert!(config.allowed_commands.is_empty());
763    }
764
765    #[test]
766    fn deserialize_shell_security_fields() {
767        let toml_str = r#"
768            [shell]
769            allowed_paths = ["/tmp", "/home/user"]
770            allow_network = false
771            confirm_patterns = ["rm ", "drop table"]
772        "#;
773
774        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
775        assert_eq!(config.shell.allowed_paths, vec!["/tmp", "/home/user"]);
776        assert!(!config.shell.allow_network);
777        assert_eq!(config.shell.confirm_patterns, vec!["rm ", "drop table"]);
778    }
779
780    #[test]
781    fn deserialize_audit_config() {
782        let toml_str = r#"
783            [audit]
784            enabled = true
785            destination = "/var/log/zeph-audit.log"
786        "#;
787
788        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
789        assert!(config.audit.enabled);
790        assert_eq!(config.audit.destination, "/var/log/zeph-audit.log");
791    }
792
793    #[test]
794    fn default_audit_config() {
795        let config = AuditConfig::default();
796        assert!(config.enabled);
797        assert_eq!(config.destination, "stdout");
798    }
799
800    #[test]
801    fn permission_policy_from_legacy_fields() {
802        let config = ToolsConfig {
803            shell: ShellConfig {
804                blocked_commands: vec!["sudo".to_owned()],
805                confirm_patterns: vec!["rm ".to_owned()],
806                ..ShellConfig::default()
807            },
808            ..ToolsConfig::default()
809        };
810        let policy = config.permission_policy(AutonomyLevel::Supervised);
811        assert_eq!(
812            policy.check("bash", "sudo apt"),
813            crate::permissions::PermissionAction::Deny
814        );
815        assert_eq!(
816            policy.check("bash", "rm file"),
817            crate::permissions::PermissionAction::Ask
818        );
819    }
820
821    #[test]
822    fn permission_policy_from_explicit_config() {
823        let toml_str = r#"
824            [permissions]
825            [[permissions.bash]]
826            pattern = "*sudo*"
827            action = "deny"
828        "#;
829        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
830        let policy = config.permission_policy(AutonomyLevel::Supervised);
831        assert_eq!(
832            policy.check("bash", "sudo rm"),
833            crate::permissions::PermissionAction::Deny
834        );
835    }
836
837    #[test]
838    fn permission_policy_default_uses_legacy() {
839        let config = ToolsConfig::default();
840        assert!(config.permissions.is_none());
841        let policy = config.permission_policy(AutonomyLevel::Supervised);
842        // Default ShellConfig has confirm_patterns, so legacy rules are generated
843        assert!(!config.shell.confirm_patterns.is_empty());
844        assert!(policy.rules().contains_key("bash"));
845    }
846
847    #[test]
848    fn deserialize_overflow_config_full() {
849        let toml_str = r"
850            [overflow]
851            threshold = 100000
852            retention_days = 14
853            max_overflow_bytes = 5242880
854        ";
855        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
856        assert_eq!(config.overflow.threshold, 100_000);
857        assert_eq!(config.overflow.retention_days, 14);
858        assert_eq!(config.overflow.max_overflow_bytes, 5_242_880);
859    }
860
861    #[test]
862    fn deserialize_overflow_config_unknown_dir_field_is_ignored() {
863        // Old configs with `dir = "..."` must not fail deserialization.
864        let toml_str = r#"
865            [overflow]
866            threshold = 75000
867            dir = "/tmp/overflow"
868        "#;
869        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
870        assert_eq!(config.overflow.threshold, 75_000);
871    }
872
873    #[test]
874    fn deserialize_overflow_config_partial_uses_defaults() {
875        let toml_str = r"
876            [overflow]
877            threshold = 75000
878        ";
879        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
880        assert_eq!(config.overflow.threshold, 75_000);
881        assert_eq!(config.overflow.retention_days, 7);
882    }
883
884    #[test]
885    fn deserialize_overflow_config_omitted_uses_defaults() {
886        let config: ToolsConfig = toml::from_str("").unwrap();
887        assert_eq!(config.overflow.threshold, 50_000);
888        assert_eq!(config.overflow.retention_days, 7);
889        assert_eq!(config.overflow.max_overflow_bytes, 10 * 1024 * 1024);
890    }
891
892    #[test]
893    fn result_cache_config_defaults() {
894        let config = ResultCacheConfig::default();
895        assert!(config.enabled);
896        assert_eq!(config.ttl_secs, 300);
897    }
898
899    #[test]
900    fn deserialize_result_cache_config() {
901        let toml_str = r"
902            [result_cache]
903            enabled = false
904            ttl_secs = 60
905        ";
906        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
907        assert!(!config.result_cache.enabled);
908        assert_eq!(config.result_cache.ttl_secs, 60);
909    }
910
911    #[test]
912    fn result_cache_omitted_uses_defaults() {
913        let config: ToolsConfig = toml::from_str("").unwrap();
914        assert!(config.result_cache.enabled);
915        assert_eq!(config.result_cache.ttl_secs, 300);
916    }
917
918    #[test]
919    fn result_cache_ttl_zero_is_valid() {
920        let toml_str = r"
921            [result_cache]
922            ttl_secs = 0
923        ";
924        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
925        assert_eq!(config.result_cache.ttl_secs, 0);
926    }
927}