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/// Per-path read allow/deny sandbox for the file tool.
424///
425/// Evaluation order: deny-then-allow. If a path matches `deny_read` and does NOT
426/// match `allow_read`, access is denied. Empty `deny_read` means no read restrictions.
427///
428/// All patterns are matched against the canonicalized (absolute, symlink-resolved) path.
429#[derive(Debug, Clone, Default, Deserialize, Serialize)]
430pub struct FileConfig {
431    /// Glob patterns for paths denied for reading. Evaluated first.
432    #[serde(default)]
433    pub deny_read: Vec<String>,
434    /// Glob patterns for paths allowed for reading. Evaluated second (overrides deny).
435    #[serde(default)]
436    pub allow_read: Vec<String>,
437}
438
439/// Top-level configuration for tool execution.
440#[derive(Debug, Deserialize, Serialize)]
441pub struct ToolsConfig {
442    #[serde(default = "default_true")]
443    pub enabled: bool,
444    #[serde(default = "default_true")]
445    pub summarize_output: bool,
446    #[serde(default)]
447    pub shell: ShellConfig,
448    #[serde(default)]
449    pub scrape: ScrapeConfig,
450    #[serde(default)]
451    pub audit: AuditConfig,
452    #[serde(default)]
453    pub permissions: Option<PermissionsConfig>,
454    #[serde(default)]
455    pub filters: crate::filter::FilterConfig,
456    #[serde(default)]
457    pub overflow: OverflowConfig,
458    #[serde(default)]
459    pub anomaly: AnomalyConfig,
460    #[serde(default)]
461    pub result_cache: ResultCacheConfig,
462    #[serde(default)]
463    pub tafc: TafcConfig,
464    #[serde(default)]
465    pub dependencies: DependencyConfig,
466    #[serde(default)]
467    pub retry: RetryConfig,
468    /// Declarative policy compiler for tool call authorization.
469    #[cfg(feature = "policy-enforcer")]
470    #[serde(default)]
471    pub policy: PolicyConfig,
472    /// LLM-based adversarial policy agent for natural-language policy enforcement.
473    #[cfg(feature = "policy-enforcer")]
474    #[serde(default)]
475    pub adversarial_policy: AdversarialPolicyConfig,
476    /// Utility-guided tool dispatch gate.
477    #[serde(default)]
478    pub utility: UtilityScoringConfig,
479    /// Per-path read allow/deny sandbox for the file tool.
480    #[serde(default)]
481    pub file: FileConfig,
482}
483
484impl ToolsConfig {
485    /// Build a `PermissionPolicy` from explicit config or legacy shell fields.
486    #[must_use]
487    pub fn permission_policy(&self, autonomy_level: AutonomyLevel) -> PermissionPolicy {
488        let policy = if let Some(ref perms) = self.permissions {
489            PermissionPolicy::from(perms.clone())
490        } else {
491            PermissionPolicy::from_legacy(
492                &self.shell.blocked_commands,
493                &self.shell.confirm_patterns,
494            )
495        };
496        policy.with_autonomy(autonomy_level)
497    }
498}
499
500/// Shell-specific configuration: timeout, command blocklist, and allowlist overrides.
501#[derive(Debug, Deserialize, Serialize)]
502#[allow(clippy::struct_excessive_bools)]
503pub struct ShellConfig {
504    #[serde(default = "default_timeout")]
505    pub timeout: u64,
506    #[serde(default)]
507    pub blocked_commands: Vec<String>,
508    #[serde(default)]
509    pub allowed_commands: Vec<String>,
510    #[serde(default)]
511    pub allowed_paths: Vec<String>,
512    #[serde(default = "default_true")]
513    pub allow_network: bool,
514    #[serde(default = "default_confirm_patterns")]
515    pub confirm_patterns: Vec<String>,
516    /// Environment variable name prefixes to strip from subprocess environment.
517    /// Variables whose names start with any of these prefixes are removed before
518    /// spawning shell commands. Default covers common credential naming conventions.
519    #[serde(default = "ShellConfig::default_env_blocklist")]
520    pub env_blocklist: Vec<String>,
521    /// Enable transactional mode: snapshot files before write commands, rollback on failure.
522    #[serde(default)]
523    pub transactional: bool,
524    /// Glob patterns defining which paths are eligible for snapshotting.
525    /// Only files matching these patterns (relative to cwd) are captured.
526    /// Empty = snapshot all files referenced in the command.
527    #[serde(default)]
528    pub transaction_scope: Vec<String>,
529    /// Automatically rollback when exit code >= 2. Default: false.
530    /// Exit code 1 is excluded because many tools (grep, diff, test) use it for
531    /// non-error conditions.
532    #[serde(default)]
533    pub auto_rollback: bool,
534    /// Exit codes that trigger auto-rollback. Default: empty (uses >= 2 heuristic).
535    /// When non-empty, only these exact exit codes trigger rollback.
536    #[serde(default)]
537    pub auto_rollback_exit_codes: Vec<i32>,
538    /// When true, snapshot failure aborts execution with an error.
539    /// When false (default), snapshot failure emits a warning and execution proceeds.
540    #[serde(default)]
541    pub snapshot_required: bool,
542    /// Maximum cumulative bytes for transaction snapshots. 0 = unlimited.
543    #[serde(default)]
544    pub max_snapshot_bytes: u64,
545}
546
547impl ShellConfig {
548    #[must_use]
549    pub fn default_env_blocklist() -> Vec<String> {
550        vec![
551            "ZEPH_".into(),
552            "AWS_".into(),
553            "AZURE_".into(),
554            "GCP_".into(),
555            "GOOGLE_".into(),
556            "OPENAI_".into(),
557            "ANTHROPIC_".into(),
558            "HF_".into(),
559            "HUGGING".into(),
560        ]
561    }
562}
563
564/// Configuration for audit logging of tool executions.
565#[derive(Debug, Deserialize, Serialize)]
566pub struct AuditConfig {
567    #[serde(default = "default_true")]
568    pub enabled: bool,
569    #[serde(default = "default_audit_destination")]
570    pub destination: String,
571}
572
573impl Default for ToolsConfig {
574    fn default() -> Self {
575        Self {
576            enabled: true,
577            summarize_output: true,
578            shell: ShellConfig::default(),
579            scrape: ScrapeConfig::default(),
580            audit: AuditConfig::default(),
581            permissions: None,
582            filters: crate::filter::FilterConfig::default(),
583            overflow: OverflowConfig::default(),
584            anomaly: AnomalyConfig::default(),
585            result_cache: ResultCacheConfig::default(),
586            tafc: TafcConfig::default(),
587            dependencies: DependencyConfig::default(),
588            retry: RetryConfig::default(),
589            #[cfg(feature = "policy-enforcer")]
590            policy: PolicyConfig::default(),
591            #[cfg(feature = "policy-enforcer")]
592            adversarial_policy: AdversarialPolicyConfig::default(),
593            utility: UtilityScoringConfig::default(),
594            file: FileConfig::default(),
595        }
596    }
597}
598
599impl Default for ShellConfig {
600    fn default() -> Self {
601        Self {
602            timeout: default_timeout(),
603            blocked_commands: Vec::new(),
604            allowed_commands: Vec::new(),
605            allowed_paths: Vec::new(),
606            allow_network: true,
607            confirm_patterns: default_confirm_patterns(),
608            env_blocklist: Self::default_env_blocklist(),
609            transactional: false,
610            transaction_scope: Vec::new(),
611            auto_rollback: false,
612            auto_rollback_exit_codes: Vec::new(),
613            snapshot_required: false,
614            max_snapshot_bytes: 0,
615        }
616    }
617}
618
619impl Default for AuditConfig {
620    fn default() -> Self {
621        Self {
622            enabled: true,
623            destination: default_audit_destination(),
624        }
625    }
626}
627
628fn default_scrape_timeout() -> u64 {
629    15
630}
631
632fn default_max_body_bytes() -> usize {
633    4_194_304
634}
635
636/// Configuration for the web scrape tool.
637#[derive(Debug, Deserialize, Serialize)]
638pub struct ScrapeConfig {
639    #[serde(default = "default_scrape_timeout")]
640    pub timeout: u64,
641    #[serde(default = "default_max_body_bytes")]
642    pub max_body_bytes: usize,
643}
644
645impl Default for ScrapeConfig {
646    fn default() -> Self {
647        Self {
648            timeout: default_scrape_timeout(),
649            max_body_bytes: default_max_body_bytes(),
650        }
651    }
652}
653
654#[cfg(test)]
655mod tests {
656    use super::*;
657
658    #[test]
659    fn deserialize_default_config() {
660        let toml_str = r#"
661            enabled = true
662
663            [shell]
664            timeout = 60
665            blocked_commands = ["rm -rf /", "sudo"]
666        "#;
667
668        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
669        assert!(config.enabled);
670        assert_eq!(config.shell.timeout, 60);
671        assert_eq!(config.shell.blocked_commands.len(), 2);
672        assert_eq!(config.shell.blocked_commands[0], "rm -rf /");
673        assert_eq!(config.shell.blocked_commands[1], "sudo");
674    }
675
676    #[test]
677    fn empty_blocked_commands() {
678        let toml_str = r"
679            [shell]
680            timeout = 30
681        ";
682
683        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
684        assert!(config.enabled);
685        assert_eq!(config.shell.timeout, 30);
686        assert!(config.shell.blocked_commands.is_empty());
687    }
688
689    #[test]
690    fn default_tools_config() {
691        let config = ToolsConfig::default();
692        assert!(config.enabled);
693        assert!(config.summarize_output);
694        assert_eq!(config.shell.timeout, 30);
695        assert!(config.shell.blocked_commands.is_empty());
696        assert!(config.audit.enabled);
697    }
698
699    #[test]
700    fn tools_summarize_output_default_true() {
701        let config = ToolsConfig::default();
702        assert!(config.summarize_output);
703    }
704
705    #[test]
706    fn tools_summarize_output_parsing() {
707        let toml_str = r"
708            summarize_output = true
709        ";
710        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
711        assert!(config.summarize_output);
712    }
713
714    #[test]
715    fn default_shell_config() {
716        let config = ShellConfig::default();
717        assert_eq!(config.timeout, 30);
718        assert!(config.blocked_commands.is_empty());
719        assert!(config.allowed_paths.is_empty());
720        assert!(config.allow_network);
721        assert!(!config.confirm_patterns.is_empty());
722    }
723
724    #[test]
725    fn deserialize_omitted_fields_use_defaults() {
726        let toml_str = "";
727        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
728        assert!(config.enabled);
729        assert_eq!(config.shell.timeout, 30);
730        assert!(config.shell.blocked_commands.is_empty());
731        assert!(config.shell.allow_network);
732        assert!(!config.shell.confirm_patterns.is_empty());
733        assert_eq!(config.scrape.timeout, 15);
734        assert_eq!(config.scrape.max_body_bytes, 4_194_304);
735        assert!(config.audit.enabled);
736        assert_eq!(config.audit.destination, "stdout");
737        assert!(config.summarize_output);
738    }
739
740    #[test]
741    fn default_scrape_config() {
742        let config = ScrapeConfig::default();
743        assert_eq!(config.timeout, 15);
744        assert_eq!(config.max_body_bytes, 4_194_304);
745    }
746
747    #[test]
748    fn deserialize_scrape_config() {
749        let toml_str = r"
750            [scrape]
751            timeout = 30
752            max_body_bytes = 2097152
753        ";
754
755        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
756        assert_eq!(config.scrape.timeout, 30);
757        assert_eq!(config.scrape.max_body_bytes, 2_097_152);
758    }
759
760    #[test]
761    fn tools_config_default_includes_scrape() {
762        let config = ToolsConfig::default();
763        assert_eq!(config.scrape.timeout, 15);
764        assert_eq!(config.scrape.max_body_bytes, 4_194_304);
765    }
766
767    #[test]
768    fn deserialize_allowed_commands() {
769        let toml_str = r#"
770            [shell]
771            timeout = 30
772            allowed_commands = ["curl", "wget"]
773        "#;
774
775        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
776        assert_eq!(config.shell.allowed_commands, vec!["curl", "wget"]);
777    }
778
779    #[test]
780    fn default_allowed_commands_empty() {
781        let config = ShellConfig::default();
782        assert!(config.allowed_commands.is_empty());
783    }
784
785    #[test]
786    fn deserialize_shell_security_fields() {
787        let toml_str = r#"
788            [shell]
789            allowed_paths = ["/tmp", "/home/user"]
790            allow_network = false
791            confirm_patterns = ["rm ", "drop table"]
792        "#;
793
794        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
795        assert_eq!(config.shell.allowed_paths, vec!["/tmp", "/home/user"]);
796        assert!(!config.shell.allow_network);
797        assert_eq!(config.shell.confirm_patterns, vec!["rm ", "drop table"]);
798    }
799
800    #[test]
801    fn deserialize_audit_config() {
802        let toml_str = r#"
803            [audit]
804            enabled = true
805            destination = "/var/log/zeph-audit.log"
806        "#;
807
808        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
809        assert!(config.audit.enabled);
810        assert_eq!(config.audit.destination, "/var/log/zeph-audit.log");
811    }
812
813    #[test]
814    fn default_audit_config() {
815        let config = AuditConfig::default();
816        assert!(config.enabled);
817        assert_eq!(config.destination, "stdout");
818    }
819
820    #[test]
821    fn permission_policy_from_legacy_fields() {
822        let config = ToolsConfig {
823            shell: ShellConfig {
824                blocked_commands: vec!["sudo".to_owned()],
825                confirm_patterns: vec!["rm ".to_owned()],
826                ..ShellConfig::default()
827            },
828            ..ToolsConfig::default()
829        };
830        let policy = config.permission_policy(AutonomyLevel::Supervised);
831        assert_eq!(
832            policy.check("bash", "sudo apt"),
833            crate::permissions::PermissionAction::Deny
834        );
835        assert_eq!(
836            policy.check("bash", "rm file"),
837            crate::permissions::PermissionAction::Ask
838        );
839    }
840
841    #[test]
842    fn permission_policy_from_explicit_config() {
843        let toml_str = r#"
844            [permissions]
845            [[permissions.bash]]
846            pattern = "*sudo*"
847            action = "deny"
848        "#;
849        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
850        let policy = config.permission_policy(AutonomyLevel::Supervised);
851        assert_eq!(
852            policy.check("bash", "sudo rm"),
853            crate::permissions::PermissionAction::Deny
854        );
855    }
856
857    #[test]
858    fn permission_policy_default_uses_legacy() {
859        let config = ToolsConfig::default();
860        assert!(config.permissions.is_none());
861        let policy = config.permission_policy(AutonomyLevel::Supervised);
862        // Default ShellConfig has confirm_patterns, so legacy rules are generated
863        assert!(!config.shell.confirm_patterns.is_empty());
864        assert!(policy.rules().contains_key("bash"));
865    }
866
867    #[test]
868    fn deserialize_overflow_config_full() {
869        let toml_str = r"
870            [overflow]
871            threshold = 100000
872            retention_days = 14
873            max_overflow_bytes = 5242880
874        ";
875        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
876        assert_eq!(config.overflow.threshold, 100_000);
877        assert_eq!(config.overflow.retention_days, 14);
878        assert_eq!(config.overflow.max_overflow_bytes, 5_242_880);
879    }
880
881    #[test]
882    fn deserialize_overflow_config_unknown_dir_field_is_ignored() {
883        // Old configs with `dir = "..."` must not fail deserialization.
884        let toml_str = r#"
885            [overflow]
886            threshold = 75000
887            dir = "/tmp/overflow"
888        "#;
889        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
890        assert_eq!(config.overflow.threshold, 75_000);
891    }
892
893    #[test]
894    fn deserialize_overflow_config_partial_uses_defaults() {
895        let toml_str = r"
896            [overflow]
897            threshold = 75000
898        ";
899        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
900        assert_eq!(config.overflow.threshold, 75_000);
901        assert_eq!(config.overflow.retention_days, 7);
902    }
903
904    #[test]
905    fn deserialize_overflow_config_omitted_uses_defaults() {
906        let config: ToolsConfig = toml::from_str("").unwrap();
907        assert_eq!(config.overflow.threshold, 50_000);
908        assert_eq!(config.overflow.retention_days, 7);
909        assert_eq!(config.overflow.max_overflow_bytes, 10 * 1024 * 1024);
910    }
911
912    #[test]
913    fn result_cache_config_defaults() {
914        let config = ResultCacheConfig::default();
915        assert!(config.enabled);
916        assert_eq!(config.ttl_secs, 300);
917    }
918
919    #[test]
920    fn deserialize_result_cache_config() {
921        let toml_str = r"
922            [result_cache]
923            enabled = false
924            ttl_secs = 60
925        ";
926        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
927        assert!(!config.result_cache.enabled);
928        assert_eq!(config.result_cache.ttl_secs, 60);
929    }
930
931    #[test]
932    fn result_cache_omitted_uses_defaults() {
933        let config: ToolsConfig = toml::from_str("").unwrap();
934        assert!(config.result_cache.enabled);
935        assert_eq!(config.result_cache.ttl_secs, 300);
936    }
937
938    #[test]
939    fn result_cache_ttl_zero_is_valid() {
940        let toml_str = r"
941            [result_cache]
942            ttl_secs = 0
943        ";
944        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
945        assert_eq!(config.result_cache.ttl_secs, 0);
946    }
947}