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